ai_co_discuss 0.2.11 → 0.2.12

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.
@@ -21,13 +21,13 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
21
21
 
22
22
  网站读写优先用 `codiscuss room …`、`codiscuss message …` 等统一命令;任务或用户显式指定 CLI 路径时沿用指定入口。未安装统一命令时,用已核实的 `python 路径/runner/discuss.py …`。统一入口需 Node.js 24+,网站读写需 Python 3.10+。裸 `codiscuss` 会启动服务;查询用法使用 `codiscuss --help` / `codiscuss schema …`,下文命令均省略入口前缀。
23
23
 
24
- 从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。站点用 `--site` 或当前进程 `DISCUSS_URL`,房间始终显式指定,业务写入显式 `--as`。首次接入读一次 `room protocol`。
24
+ 从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。站点用 `--site` 或当前进程 `DISCUSS_URL`,房间始终显式指定,业务写入显式 `--as`。手动首次接入读一次 `room protocol`;自动任务不读,需要字段时只查 `schema 具体命令`,不列全部命令。
25
25
 
26
26
  项目与 cwd 可缺省,有值时用于本次获准资料;CLI 路径只定位工具,不把 CLI 仓库当作讨论项目或扩大文件权限。
27
27
 
28
28
  **自动任务(收到 `*.task.json`):**
29
29
 
30
- - 从任务文件取得身份、职责、事件、安排、CLI 和 `access_file`。按任务指令以 `execution enter ROOM EVENT --access-file FILE` 认领一次;**若当前调用刚已成功 enter,直接沿用,不再执行第二次**。会话 ID 由接入程序提供,不编造、不打印凭据内容。
30
+ - 从任务文件取得身份、职责、事件、安排、CLI 和 `access_file`。按任务指令以 `execution enter ROOM EVENT --access-file FILE` 认领一次;**若当前调用刚已成功 enter,直接沿用,不再执行第二次**。若提示词写明接入程序已进入,则跳过 enter。会话 ID 由接入程序提供,不编造、不打印凭据内容。
31
31
  - 进入失败或重复交付时,用 `execution view` 和实际业务结果核对;仅凭事件显示 started,不能认定可以再做一遍。暂停、到期或失效的旧任务不得继续写入。
32
32
  - 本次受保护写入统一附同一 `--access-file`。只处理交付的职责和安排;专家提交答案即完成此执行,主持人的交接边界见主持技能。
33
33
  - 通知程序负责续租与后续交付。AI 不创建定时任务、不操作其他软件,不另发“就绪/完成”回执。确实无法继续时保留草稿,用 `execution fail ROOM EVENT --reason 原因 --access-file FILE` 暂停;已失效时不再写回执或清理消息。
package/docs/runner.md CHANGED
@@ -228,7 +228,7 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
228
228
 
229
229
  进度看板的参与者姓名下显示本场已登记的模型与思考强度,不使用新建草稿或本机默认配置代替。由本机桥接的只读 `room-models` 返回 `adapter/model/effort`,按当前站点与房间匹配;本机页面和已授权连接的线上页面均可读取,不要求暂停或浏览器持有房间控制凭据,不返回路径、令牌或会话编号。CLI 未显式指定时显示“默认模型 / 默认强度”,agy 显示“强度随模型”,App 和手动参与提示在原软件内设置;未连接本机、未登记或旧版服务不支持时显示未获取。它展示启动配置,不能证明软件最终采用的默认值,也不提供讨论中改模型的入口。
230
230
 
231
- 两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID 在输出流到达时写入本机绑定和本次执行凭据,随后 AI 按任务文件自行 `execution enter` 并通过讨论 CLI 保存业务结果;程序不把终端回答直接代发。Codex App / CLI 可通过上述暂停后配置修正保留原绑定切换;其他适配器或项目、会话变更仍须使用独立 `data_directory` 明确重新接入。此路径不承诺 CLI 会话自动出现在桌面 App 的项目列表。
231
+ 两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID 在输出流到达时写入本机绑定和本次执行凭据,随后由接入程序在会话启动时直接 `execution enter`(`codex_cli` 与 `claude`,任务内容内联进提示词,省去模型读文件和进入的往返;其余适配器仍由 AI 自行进入),AI 通过讨论 CLI 保存业务结果;Codex CLI 输出 `turn.completed` 1.5 秒后若进程未退出即终止,避免同会话的下一次投递等待收尾。每次执行在任务目录写 `event-ID.trace.jsonl`(时间戳加阶段:`event_seen`、`blocked_by_running_process`、`reserve_*`、`permission_check_*`、`cli_spawned`、`enter_*`、Codex 的 `thread.started` / `turn.started` / 工具命令 / `turn.completed`、`kill_after_completed`、`cli_closed`),仅用于定位各阶段耗时,不含凭据;程序不把终端回答直接代发。Codex App / CLI 可通过上述暂停后配置修正保留原绑定切换;其他适配器或项目、会话变更仍须使用独立 `data_directory` 明确重新接入。此路径不承诺 CLI 会话自动出现在桌面 App 的项目列表。
232
232
 
233
233
  `codex_cli` 的 `exec resume` 需要取得该会话的写入权;已有聊天仍被 Codex App 或另一进程持有时,会报 `thread-store conflict / already has an active writer`,App 显示空闲也不代表已释放。重复重试或仅关闭窗口不能保证解除占用。需要沿用 App 原聊天时,暂停后在本场接入设置中切到 `codex`,由 `codex queue` 向原聊天交付;需要独立 CLI 时,使用未被占用的 CLI 会话或新建讨论时省略会话 ID。接入程序保留这一根因,不让后续模型列表刷新超时覆盖它,也不自动换会话或切换适配器。
234
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -10,6 +10,8 @@ import { CodexAppClient } from './codex_app_client.mjs';
10
10
 
11
11
  const CALL_TIMEOUT_MS = 30_000;
12
12
  const CLI_DIAGNOSTIC_CHARS = 1000;
13
+ /** Codex exec may linger after its final event; the finished turn is already saved, so stop it to free the session. */
14
+ const CLI_EXIT_GRACE_MS = 1500;
13
15
  const CODEX_PERMISSIONS_MIN_MINOR = 163;
14
16
  /** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
15
17
  export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'floor', 'execution', 'health', 'schema'];
@@ -369,7 +371,7 @@ function bind_cli_session(state, session_id, task) {
369
371
  function cli_event(adapter, state, event, task) {
370
372
  if (adapter === 'codex_cli') {
371
373
  if (event.type === 'thread.started') bind_cli_session(state, event.thread_id, task);
372
- if (event.type === 'turn.completed') state.completed = true;
374
+ if (event.type === 'turn.completed') { state.completed = true; state.finish_timer = setTimeout(() => { state.finished = true; task.on_trace?.('kill_after_completed'); state.child.kill(); }, CLI_EXIT_GRACE_MS); }
373
375
  if (event.type === 'turn.failed') state.error = event.error?.message || 'Codex CLI 执行失败';
374
376
  if (event.type === 'error') state.diagnostic = event.message || 'Codex CLI 返回错误';
375
377
  remember_writer_conflict(state, state.error || state.diagnostic);
@@ -384,6 +386,14 @@ function cli_event(adapter, state, event, task) {
384
386
  }
385
387
  }
386
388
 
389
+ /** Record lifecycle events and tool commands with timestamps so slow stages can be located from the trace file. */
390
+ function trace_cli_event(event, task) {
391
+ if (!task.on_trace) return;
392
+ const item = event.item;
393
+ const command = item?.command ? String(item.command).replace(/^.*?-Command\s+/, '').slice(0, 100) : '';
394
+ task.on_trace(item ? `${event.type}:${item.type}` : event.type, command ? { command } : {});
395
+ }
396
+
387
397
  /** Keep the actual resume failure even when later model-refresh logs replace the diagnostic tail. */
388
398
  function remember_writer_conflict(state, diagnostic) {
389
399
  if (/thread-store conflict:[\s\S]*already has an active writer/.test(diagnostic)) state.writer_conflict = true;
@@ -393,6 +403,7 @@ function remember_writer_conflict(state, diagnostic) {
393
403
  function cli_exit_error(state, code, signal) {
394
404
  if (state.writer_conflict) return `Codex 会话 ${state.session_id} 已被另一进程持有(active writer),CLI 无法接管。若为 App 中的聊天,请使用 Codex App 接入;独立 CLI 须使用未被占用的会话。重复点击重试不会解除占用`;
395
405
  if (state.error) return state.error;
406
+ if (state.finished && state.completed) return '';
396
407
  if (code !== 0) return `CLI ${signal ? `被信号 ${signal} 终止` : `退出码 ${code}`}:${state.diagnostic}`;
397
408
  if (!state.initialized) return `CLI 未确认实际会话 ID:${state.diagnostic}`;
398
409
  if (!state.completed) return `CLI 未返回成功结束事件:${state.diagnostic}`;
@@ -401,7 +412,7 @@ function cli_exit_error(state, code, signal) {
401
412
 
402
413
  /** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
403
414
  function monitor_cli(child, config, binding, task) {
404
- const state = { session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
415
+ const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
405
416
  const log_path = resolve(task.directory, `${config.adapter}-${task.id}.log`);
406
417
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
407
418
  child.stdout.on('data', chunk => task.on_output(log_path, chunk));
@@ -416,10 +427,13 @@ function monitor_cli(child, config, binding, task) {
416
427
  let event;
417
428
  try { event = JSON.parse(line); }
418
429
  catch { state.diagnostic = 'CLI 输出中存在无效 JSON,详情见本机日志'; return; }
430
+ trace_cli_event(event, task);
419
431
  try { cli_event(config.adapter, state, event, task); }
420
432
  catch (error) { state.error = error.message; child.kill(); }
421
433
  });
422
434
  child.once('close', (code, signal) => {
435
+ clearTimeout(state.finish_timer);
436
+ task.on_trace?.('cli_closed', { code, signal, killed_after_completed: Boolean(state.finished) });
423
437
  if (!state.spawned) return;
424
438
  const error = cli_exit_error(state, code, signal);
425
439
  if (error) task.on_failure(error);
@@ -437,7 +451,11 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
437
451
  let invocation;
438
452
  try { invocation = cli_invocation(config, binding, task, user_home); }
439
453
  catch (error) { throw not_sent(error); }
440
- if (invocation.permissions) await check_codex_permissions(config, task, invocation.permissions, spawn_process);
454
+ if (invocation.permissions) {
455
+ task.on_trace?.('permission_check_start');
456
+ await check_codex_permissions(config, task, invocation.permissions, spawn_process);
457
+ task.on_trace?.('permission_check_done');
458
+ }
441
459
  if (invocation.project) {
442
460
  const on_binding = task.on_binding;
443
461
  task = { ...task, on_binding: session_id => { invocation.project.bind(session_id); on_binding(session_id); } };
@@ -451,11 +469,12 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
451
469
  const state = monitor_cli(child, config, binding, task);
452
470
  task.on_process(child);
453
471
  await new Promise((resolve_spawn, reject) => {
454
- child.once('spawn', () => { state.spawned = true; resolve_spawn(); });
472
+ child.once('spawn', () => { state.spawned = true; task.on_trace?.('cli_spawned'); resolve_spawn(); });
455
473
  child.once('error', error => state.spawned ? task.on_failure(error.message) : reject(not_sent(error)));
456
474
  });
457
475
  child.stdin.on('error', error => { if (error.code !== 'EPIPE') task.on_failure(error.message); });
458
476
  child.stdin.end(invocation.input);
477
+ task.on_trace?.('prompt_sent');
459
478
  return { session_id: state.session_id, status: 'accepted' };
460
479
  }
461
480
 
@@ -55,13 +55,16 @@ conversation 还须 current_session_id,当前会话接手首个主持任务。
55
55
  详见 docs/runner.md 的“两种讨论入口与启动配置”。
56
56
  `;
57
57
 
58
- // Windows PowerShell 5.1 ships WinForms; a topmost owner keeps the dialog above the browser that asked for it.
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.
59
60
  const WINDOWS_PICKER = [
60
61
  'Add-Type -AssemblyName System.Windows.Forms',
61
62
  '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
62
- '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false }',
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()',
63
65
  '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
64
66
  'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
67
+ '$owner.Close()',
65
68
  ].join('; ');
66
69
  const PICKERS = {
67
70
  win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
@@ -1,398 +1,434 @@
1
- /** Durable website-event delivery to existing AI software; no model API orchestration. */
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
4
- import { dirname, join, resolve } from 'node:path';
5
- import { DatabaseSync } from 'node:sqlite';
6
- import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { parseArgs } from 'node:util';
8
- import { setTimeout as sleep } from 'node:timers/promises';
9
- import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
10
-
11
- const POLL_MS = 2000;
12
- const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
13
- const RENEW_MS = 30_000;
14
- const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli'];
15
- export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
16
- const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
- /** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
18
- const PROJECT_ACCESS = ['read', 'write'];
19
-
20
- /** Explicit per-room configuration is the only source of software paths and capabilities. */
21
- export function load_config(path) {
22
- return validate_config(JSON.parse(readFileSync(path, 'utf8')), dirname(resolve(path)));
23
- }
24
-
25
- /** Keep service-managed answers outside all room credentials, logs and databases. */
26
- export function service_drafts(home, directory) {
27
- const key = createHash('sha256').update(resolve(directory)).digest('hex');
28
- return join(home, 'drafts', key);
29
- }
30
-
31
- /** Validate a launch draft with the same rules as a registered room configuration. */
32
- export function validate_config(input, base_directory) {
33
- const config = structuredClone(input);
34
- config.project = config.cli_project || config.project || REPO_ROOT;
35
- const url = new URL(config.site);
36
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('站点必须是无路径和凭据的 HTTP(S) 根地址');
37
- if (!/^[A-Za-z0-9_-]+$/.test(config.room ?? '') || typeof config.token !== 'string' || config.token.length < 32) throw new Error('缺少房间及本房间凭据');
38
- if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
39
- if (!config.participants || !Object.keys(config.participants).length) throw new Error('请配置本机参与软件');
40
- for (const participant of Object.values(config.participants)) {
41
- participant.cwd = participant.cwd ?? config.cwd;
42
- participant.project_name = participant.project_name ?? config.discussion_project;
43
- participant.project_access = participant.project_access || config.project_access || 'read';
44
- if (!PROJECT_ACCESS.includes(participant.project_access)) throw new Error('project_access 只能为 read 或 write');
45
- if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
46
- if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
47
- if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
48
- if (participant.adapter !== 'antigravity' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
49
- if (participant.adapter === 'codex' && !participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
50
- }
51
- if (![undefined, 'free', 'brainstorm'].includes(config.mode)) throw new Error('房间模式只能为 free 或 brainstorm');
52
- if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
53
- throw new Error('自由房间须为每位成员保存成员凭证');
54
- }
55
- return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
56
- }
57
-
58
- /** Send exactly one bounded website request; never follow redirects with a capability. */
59
- export async function api(config, tail, body, token = config.token) {
60
- const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
61
- method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
62
- headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
63
- body: body === undefined ? undefined : JSON.stringify(body),
64
- });
65
- const result = await response.json();
66
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
67
- return result;
68
- }
69
-
70
- /** Persistent inbox, cursor and bindings share one local SQLite transaction boundary. */
71
- export class DiscussionStore {
72
- constructor(directory) {
73
- mkdirSync(directory, { recursive: true });
74
- this.db = new DatabaseSync(join(directory, 'runtime.sqlite3'));
75
- this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
76
- CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
77
- CREATE TABLE IF NOT EXISTS inbox(id INTEGER PRIMARY KEY,event TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
78
- token TEXT NOT NULL DEFAULT '',sent_at INTEGER NOT NULL DEFAULT 0,last_renew INTEGER NOT NULL DEFAULT 0,error TEXT NOT NULL DEFAULT '');
79
- CREATE TABLE IF NOT EXISTS bindings(participant TEXT PRIMARY KEY,session_id TEXT NOT NULL);`);
80
- const columns = new Set(this.db.prepare('PRAGMA table_info(inbox)').all().map(column => column.name));
81
- for (const [name, definition] of Object.entries({ attempts: 'INTEGER NOT NULL DEFAULT 0',
82
- retry_at: 'INTEGER NOT NULL DEFAULT 0', receipt: "TEXT NOT NULL DEFAULT ''" })) {
83
- if (!columns.has(name)) this.db.exec(`ALTER TABLE inbox ADD COLUMN ${name} ${definition}`);
84
- }
85
- }
86
- /** Atomically receive a page before moving its cursor. */
87
- receive(page) {
88
- this.db.exec('BEGIN IMMEDIATE');
89
- try {
90
- for (const event of page.events) this.db.prepare('INSERT OR IGNORE INTO inbox(id,event) VALUES (?,?)').run(event.id, JSON.stringify(event));
91
- this.db.prepare("INSERT INTO metadata VALUES ('cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(page.next_after));
92
- this.db.exec('COMMIT');
93
- } catch (error) { this.db.exec('ROLLBACK'); throw error; }
94
- }
95
- /** Return the last durably ingested sequence. */
96
- cursor() { return Number(this.db.prepare("SELECT value FROM metadata WHERE key='cursor'").get()?.value || 0); }
97
- /** Never reuse another site's cursor or a previous runtime's software binding. */
98
- scope(config, run_id) {
99
- const scope = `${config.site}/${config.room}/${run_id}`;
100
- const previous = this.db.prepare("SELECT value FROM metadata WHERE key='scope'").get()?.value;
101
- if (previous && previous !== scope) throw new Error('数据目录已绑定另一站点、房间或运行;请选择独立目录');
102
- this.db.prepare("INSERT OR IGNORE INTO metadata VALUES ('scope',?)").run(scope);
103
- }
104
- /** Do not silently reuse a conversation after its software project or cwd changes. */
105
- context(config, allow_codex_switch = false) {
106
- const entries = Object.entries(config.participants).sort(([left], [right]) => left.localeCompare(right))
107
- .map(([name, item]) => [name, item.adapter, item.project_id || '', item.project_name || '',
108
- item.cwd ? resolve(item.cwd) : '', item.workdir ? resolve(item.workdir) : '', item.session_id || '']);
109
- const context = JSON.stringify(entries);
110
- const previous = this.db.prepare("SELECT value FROM metadata WHERE key='context'").get()?.value;
111
- if (previous === context) return;
112
- if (previous && previous !== context) {
113
- const before = JSON.parse(previous);
114
- const allowed = allow_codex_switch && before.length === entries.length && before.every((item, index) => {
115
- const next = entries[index];
116
- if (JSON.stringify(item) === JSON.stringify(next)) return true;
117
- const session = this.binding(item[0]).session_id || item[6];
118
- return item[0] === next[0] && item[1] !== next[1] && CODEX_ADAPTERS.includes(item[1])
119
- && CODEX_ADAPTERS.includes(next[1]) && session && next[6] === session && (!item[6] || item[6] === session)
120
- && JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
121
- });
122
- if (!allowed) throw new Error('已绑定会话的项目、cwd 或身份配置发生变化;请暂停并使用独立运行目录重新接入');
123
- }
124
- this.db.prepare("INSERT INTO metadata VALUES ('context',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(context);
125
- }
126
- /** Persist only known internal fields, never dynamic SQL column names. */
127
- record(id, fields) {
128
- const allowed = ['status', 'token', 'sent_at', 'last_renew', 'error', 'attempts', 'retry_at', 'receipt'];
129
- const entries = Object.entries(fields);
130
- if (entries.some(([key]) => !allowed.includes(key))) throw new Error('无效的内部状态字段');
131
- this.db.prepare(`UPDATE inbox SET ${entries.map(([key]) => `${key}=?`).join(',')} WHERE id=?`)
132
- .run(...entries.map(([, value]) => value), id);
133
- }
134
- /** Resolve actual session bindings without reading unrelated conversations. */
135
- binding(participant) { return this.db.prepare('SELECT session_id FROM bindings WHERE participant=?').get(participant) || {}; }
136
- /** Commit a returned session before later notifications can use it. */
137
- bind(participant, session_id) { this.db.prepare('INSERT INTO bindings VALUES (?,?) ON CONFLICT(participant) DO UPDATE SET session_id=excluded.session_id').run(participant, session_id); }
138
- /** List work that still needs dispatch or reconciliation. */
139
- pending() { return this.db.prepare("SELECT * FROM inbox WHERE status NOT IN ('completed','cancelled','failed') ORDER BY id").all(); }
140
- /** Release this instance's database connection. */
141
- close() { this.db.close(); }
142
- }
143
-
144
- /** Hold one PID lock per runtime directory, without taking over a live owner. */
145
- export function acquire_lock(directory) {
146
- mkdirSync(directory, { recursive: true });
147
- const path = join(directory, 'runner.lock');
148
- if (existsSync(path)) {
149
- const pid = Number(readFileSync(path, 'utf8'));
150
- let alive = true;
151
- try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
152
- if (alive) throw new Error('此目录已有通知程序运行;不重复启动');
153
- unlinkSync(path);
154
- }
155
- writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
156
- return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
157
- }
158
-
159
- /** Write a non-secret task separately from its CLI-only credential file. */
160
- export function task_files(config, event, token, binding) {
161
- const id = `event-${event.id}`;
162
- const draft_directory = config.draft_directory || `${config.directory}-drafts`;
163
- mkdirSync(draft_directory, { recursive: true });
164
- const access_path = join(config.directory, `${id}.access.json`);
165
- const task_path = join(config.directory, `${id}.task.json`);
166
- atomic_json(access_path, { site: config.site, room: config.room, token, session_id: binding.session_id || '' });
167
- const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
168
- if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
169
- const adapter = config.participants[event.participant];
170
- const skills_directory = join(config.project, '.agents', 'skills');
171
- // Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
172
- const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
173
- const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
174
- : adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
175
- atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
176
- project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
177
- draft_directory,
178
- role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
179
- instructions: `读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。凭据文件由 CLI 内部读取,不要打印或读取其 token。进入成功后才处理本次安排;每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
180
- const prompt = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。先读取任务文件:${task_path},立即用 ${cli} execution enter 进入,再读取指定版本的技能和网站现状。本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。实际会话 ID 由接入程序写入凭据文件,尚未就绪只短暂重查,不编造。`;
181
- return { id, participant: event.participant, directory: config.directory, draft_directory, skills_directory, access_path, prompt };
182
- }
183
-
184
- /** A running dispatcher retains only the processes it created itself. */
185
- export class DiscussionRunner {
186
- constructor(config, store, deliver = deliver_task) {
187
- this.config = config;
188
- this.store = store;
189
- this.deliver = deliver;
190
- this.children = new Set();
191
- this.exited = new Set();
192
- this.running = new Map();
193
- }
194
- /** Reserve with a durable token so a lost HTTP response can be reconciled. */
195
- async dispatch(row, event) {
196
- const token = row.token || randomUUID();
197
- this.store.record(row.id, { token, status: 'reserving' });
198
- await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
199
- await this.deliver_reserved({ ...row, token }, event);
200
- }
201
- /** Retry only proven non-delivery, keeping the event, token and task unchanged. */
202
- async deliver_reserved(row, event) {
203
- const adapter = this.config.participants[event.participant];
204
- if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
205
- const binding = this.store.binding(event.participant);
206
- if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
207
- const attempts = row.attempts + 1;
208
- this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
209
- let invoked = false;
210
- try {
211
- const task = task_files(this.config, event, row.token, binding);
212
- invoked = true;
213
- const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
214
- on_binding: session => this.store.bind(event.participant, session),
215
- on_process: child => this.track(event.participant, child),
216
- on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
217
- on_failure: error => { try { this.store.record(row.id, { error }); } catch { /* Store may already be closed at shutdown. */ } },
218
- on_exit: () => this.exited.add(row.id),
219
- });
220
- if (result.session_id) this.store.bind(event.participant, result.session_id);
221
- this.store.record(row.id, { status: result.status, receipt: result.receipt || '' });
222
- } catch (error) {
223
- const not_sent = !invoked || error.delivery_status === 'not_sent';
224
- this.store.record(row.id, { status: not_sent ? 'retry_wait' : 'uncertain', error: error.message,
225
- retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
226
- }
227
- }
228
- /** Reconcile business evidence; accepted delivery alone never completes a task. */
229
- async reconcile(row, event) {
230
- if (['completed', 'cancelled', 'failed'].includes(event.status)) {
231
- this.store.record(row.id, { status: event.status });
232
- return;
233
- }
234
- // No app call occurs before dispatching is persisted. Reconcile a reserve response loss safely.
235
- if (row.status === 'reserving') {
236
- if (event.status === 'pending') return this.dispatch(row, event);
237
- if (event.status === 'dispatched') {
238
- const reserved = await api(this.config, `/events/${row.id}`, { action: 'view' }, row.token);
239
- if (reserved.status !== 'dispatched') return this.reconcile(row, reserved);
240
- return this.deliver_reserved(row, reserved);
241
- }
242
- }
243
- if (row.status === 'retry_wait' && event.status === 'dispatched') {
244
- if (row.attempts > DELIVERY_RETRY_DELAYS_MS.length) return this.fail(row, `投递 ${row.attempts} 次均确认未送出:${row.error}`);
245
- if (Date.now() >= row.retry_at) await this.deliver_reserved(row, event);
246
- return;
247
- }
248
- const adapter = this.config.participants[event.participant];
249
- if (adapter?.adapter === 'antigravity' && event.status === 'dispatched') {
250
- const receipt = sidecar_receipt(adapter, `event-${row.id}`);
251
- if (receipt?.session_id) this.store.bind(event.participant, receipt.session_id);
252
- if (receipt) this.store.record(row.id, { status: receipt.status, receipt: JSON.stringify(receipt), error: receipt.error || '' });
253
- }
254
- if (row.error && ['accepted', 'started'].includes(row.status)) return this.fail(row, row.error);
255
- if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
256
- // A business write may have just completed the task; recheck before declaring a silent exit.
257
- const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
258
- if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果`);
259
- return this.reconcile(row, latest);
260
- }
261
- if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
262
- if (event.status === 'started' && Date.now() - row.last_renew >= RENEW_MS) {
263
- try { await api(this.config, `/events/${row.id}`, { action: 'renew' }); }
264
- catch (error) {
265
- if (/HTTP (403|409)/.test(error.message)) return this.fail(row, '执行时限已到;请核对原会话');
266
- throw error;
267
- }
268
- this.store.record(row.id, { status: 'started', last_renew: Date.now(), error: '' });
269
- }
270
- }
271
- /** Preserve the cause on the website and revoke work before asking for intervention. */
272
- async fail(row, reason) {
273
- await api(this.config, `/events/${row.id}`, { action: 'fail', reason });
274
- this.store.record(row.id, { status: 'failed', error: reason });
275
- this.stop_children();
276
- }
277
- /** Process one bounded iteration without waking models for empty checks. */
278
- async tick() {
279
- this.store.context(this.config);
280
- let room;
281
- try { room = await api(this.config, ''); }
282
- catch (error) {
283
- // Only a permanently deleted room drops its private agy projects; concluded or archived rooms keep them for later continuation.
284
- if (/^HTTP 404: 房间不存在/.test(error.message)) remove_agy_projects(this.config.directory);
285
- throw error;
286
- }
287
- if (room.limits?.reached && (!room.limits.closing || room.limits.reason === 'time_limit') && room.runtime.status === 'running') {
288
- const generation = room.runtime.generation;
289
- room = await api(this.config, '/limits', { actor: '用户' });
290
- if (room.runtime.generation !== generation) this.stop_children();
291
- }
292
- this.room_title = room.title;
293
- const run = room.runtime;
294
- if (run.run_id) this.store.scope(this.config, run.run_id);
295
- if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
296
- this.stop_children();
297
- if (run.expired) await api(this.config, '/runtime', { action: 'pause', revision: run.revision });
298
- for (const row of this.store.pending()) {
299
- const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
300
- if (['completed', 'cancelled', 'failed'].includes(event.status)) this.store.record(row.id, { status: event.status });
301
- }
302
- // A paused automatic run waits so the web restart can resume it; stop, conclusion or manual mode ends the loop.
303
- return run.mode === 'automatic' && (run.status === 'paused' || run.expired) ? 'waiting' : false;
304
- }
305
- this.store.receive(await api(this.config, `/events?after=${this.store.cursor()}`));
306
- const rows = this.store.pending();
307
- for (const row of rows.filter(item => item.status !== 'pending')) {
308
- const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
309
- await this.reconcile(row, event);
310
- }
311
- if (this.store.pending().some(row => row.status !== 'pending')) return;
312
- const current = room.current_turn;
313
- const pending = rows.filter(row => row.status === 'pending').map(row => ({ row, event: JSON.parse(row.event) }));
314
- const eligible = pending.filter(({ event }) => event.generation === run.generation && event.run_id === run.run_id
315
- && (current && ['pending', 'notified'].includes(current.status) ? event.turn_id === current.id : event.role === 'host'));
316
- for (const { row, event } of pending) {
317
- if (event.generation !== run.generation || event.run_id !== run.run_id) this.store.record(row.id, { status: 'cancelled' });
318
- }
319
- const next = eligible.at(-1);
320
- // A host that just saved the next turn may still be exiting; the same session cannot accept a second writer yet.
321
- if (next && !this.running.get(next.event.participant)) {
322
- const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
323
- if (fresh.status === 'pending') {
324
- await this.dispatch(next.row, fresh);
325
- }
326
- else this.store.record(next.row.id, { status: fresh.status });
327
- }
328
- }
329
- /** Count live processes per participant so a resumed session never overlaps its previous writer. */
330
- track(participant, child) {
331
- this.children.add(child);
332
- this.running.set(participant, (this.running.get(participant) || 0) + 1);
333
- child.once('close', () => {
334
- this.children.delete(child);
335
- this.running.set(participant, this.running.get(participant) - 1);
336
- });
337
- }
338
- /** Stop only child processes created by this runner, never the desktop apps. */
339
- stop_children() { for (const child of this.children) child.kill(); }
340
- }
341
-
342
- /** Pause a running automatic run so the website never shows work nobody will consume. */
343
- export async function pause_if_running(config) {
344
- const latest = await api(config, '/runtime');
345
- if (latest.status === 'running') await api(config, '/runtime', { action: 'pause', revision: latest.revision });
346
- }
347
-
348
- /** Run an explicitly configured room; install and status never start a discussion. */
349
- export async function main(args = process.argv.slice(2)) {
350
- const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
351
- config: { type: 'string' }, once: { type: 'boolean' }, minutes: { type: 'string' },
352
- } });
353
- if (!values.config || !['watch', 'start', 'status', 'pause', 'setup-sidecar'].includes(positionals[0])) {
354
- throw new Error('用法: discussion_runner.mjs watch|start|status|pause|setup-sidecar --config 私有配置.json [--minutes 60] [--once]');
355
- }
356
- const config = load_config(values.config);
357
- if (positionals[0] === 'setup-sidecar') {
358
- for (const participant of Object.values(config.participants).filter(item => item.adapter === 'antigravity')) process.stdout.write(JSON.stringify(setup_sidecar(participant)) + '\n');
359
- return;
360
- }
361
- const run = await api(config, '/runtime');
362
- if (positionals[0] === 'status') { process.stdout.write(JSON.stringify(run) + '\n'); return; }
363
- if (positionals[0] === 'pause') { await api(config, '/runtime', { action: 'pause', revision: run.revision }); return; }
364
- const release = acquire_lock(config.directory);
365
- const store = new DiscussionStore(config.directory);
366
- const runner = new DiscussionRunner(config, store);
367
- let stopped = false;
368
- const stop = () => { stopped = true; };
369
- process.once('SIGINT', stop);
370
- process.once('SIGTERM', stop);
371
- try {
372
- store.context(config);
373
- if (run.run_id) store.scope(config, run.run_id);
374
- if (positionals[0] === 'start') {
375
- const room = await api(config, '');
376
- const minutes = Number(values.minutes ?? room.limits?.max_minutes ?? 0);
377
- if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) throw new Error('总时长或 --minutes 须在 [0,1440],0 表示不限时长');
378
- if (![room.host, ...Object.keys(room.experts)].every(name => config.participants[name])) throw new Error('尚未配置全部参与者的本机软件');
379
- await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: run.revision, deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
380
- }
381
- do {
382
- try { if (await runner.tick() === false) break; }
383
- catch (error) { process.stderr.write(JSON.stringify({ error: error.message }) + '\n'); if (values.once) throw error; }
384
- if (!values.once && !stopped) await sleep(POLL_MS);
385
- } while (!values.once && !stopped);
386
- } finally {
387
- if (stopped) {
388
- try { await pause_if_running(config); }
389
- catch (error) { process.stderr.write(`停止回执未确认;租约到期后拒绝写入:${error.message}\n`); }
390
- }
391
- runner.stop_children(); store.close(); release();
392
- process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
393
- }
394
- }
395
-
396
- if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
397
- main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
398
- }
1
+ /** Durable website-event delivery to existing AI software; no model API orchestration. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { parseArgs } from 'node:util';
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
10
+
11
+ const POLL_MS = 2000;
12
+ /** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
13
+ const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude']);
14
+ const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
15
+ const RENEW_MS = 30_000;
16
+ const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli'];
17
+ export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
18
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
19
+ /** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
20
+ const PROJECT_ACCESS = ['read', 'write'];
21
+
22
+ /** Explicit per-room configuration is the only source of software paths and capabilities. */
23
+ export function load_config(path) {
24
+ return validate_config(JSON.parse(readFileSync(path, 'utf8')), dirname(resolve(path)));
25
+ }
26
+
27
+ /** Keep service-managed answers outside all room credentials, logs and databases. */
28
+ export function service_drafts(home, directory) {
29
+ const key = createHash('sha256').update(resolve(directory)).digest('hex');
30
+ return join(home, 'drafts', key);
31
+ }
32
+
33
+ /** Validate a launch draft with the same rules as a registered room configuration. */
34
+ export function validate_config(input, base_directory) {
35
+ const config = structuredClone(input);
36
+ config.project = config.cli_project || config.project || REPO_ROOT;
37
+ const url = new URL(config.site);
38
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('站点必须是无路径和凭据的 HTTP(S) 根地址');
39
+ if (!/^[A-Za-z0-9_-]+$/.test(config.room ?? '') || typeof config.token !== 'string' || config.token.length < 32) throw new Error('缺少房间及本房间凭据');
40
+ if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
41
+ if (!config.participants || !Object.keys(config.participants).length) throw new Error('请配置本机参与软件');
42
+ for (const participant of Object.values(config.participants)) {
43
+ participant.cwd = participant.cwd ?? config.cwd;
44
+ participant.project_name = participant.project_name ?? config.discussion_project;
45
+ participant.project_access = participant.project_access || config.project_access || 'read';
46
+ if (!PROJECT_ACCESS.includes(participant.project_access)) throw new Error('project_access 只能为 read 或 write');
47
+ if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
48
+ if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
49
+ if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
50
+ if (participant.adapter !== 'antigravity' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
51
+ if (participant.adapter === 'codex' && !participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
52
+ }
53
+ if (![undefined, 'free', 'brainstorm'].includes(config.mode)) throw new Error('房间模式只能为 free 或 brainstorm');
54
+ if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
55
+ throw new Error('自由房间须为每位成员保存成员凭证');
56
+ }
57
+ return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
58
+ }
59
+
60
+ /** Send exactly one bounded website request; never follow redirects with a capability. */
61
+ export async function api(config, tail, body, token = config.token) {
62
+ const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
63
+ method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
64
+ headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
65
+ body: body === undefined ? undefined : JSON.stringify(body),
66
+ });
67
+ const result = await response.json();
68
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
69
+ return result;
70
+ }
71
+
72
+ /** Persistent inbox, cursor and bindings share one local SQLite transaction boundary. */
73
+ export class DiscussionStore {
74
+ constructor(directory) {
75
+ mkdirSync(directory, { recursive: true });
76
+ this.db = new DatabaseSync(join(directory, 'runtime.sqlite3'));
77
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
78
+ CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
79
+ CREATE TABLE IF NOT EXISTS inbox(id INTEGER PRIMARY KEY,event TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
80
+ token TEXT NOT NULL DEFAULT '',sent_at INTEGER NOT NULL DEFAULT 0,last_renew INTEGER NOT NULL DEFAULT 0,error TEXT NOT NULL DEFAULT '');
81
+ CREATE TABLE IF NOT EXISTS bindings(participant TEXT PRIMARY KEY,session_id TEXT NOT NULL);`);
82
+ const columns = new Set(this.db.prepare('PRAGMA table_info(inbox)').all().map(column => column.name));
83
+ for (const [name, definition] of Object.entries({ attempts: 'INTEGER NOT NULL DEFAULT 0',
84
+ retry_at: 'INTEGER NOT NULL DEFAULT 0', receipt: "TEXT NOT NULL DEFAULT ''" })) {
85
+ if (!columns.has(name)) this.db.exec(`ALTER TABLE inbox ADD COLUMN ${name} ${definition}`);
86
+ }
87
+ }
88
+ /** Atomically receive a page before moving its cursor. */
89
+ receive(page) {
90
+ this.db.exec('BEGIN IMMEDIATE');
91
+ try {
92
+ for (const event of page.events) this.db.prepare('INSERT OR IGNORE INTO inbox(id,event) VALUES (?,?)').run(event.id, JSON.stringify(event));
93
+ this.db.prepare("INSERT INTO metadata VALUES ('cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(page.next_after));
94
+ this.db.exec('COMMIT');
95
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
96
+ }
97
+ /** Return the last durably ingested sequence. */
98
+ cursor() { return Number(this.db.prepare("SELECT value FROM metadata WHERE key='cursor'").get()?.value || 0); }
99
+ /** Never reuse another site's cursor or a previous runtime's software binding. */
100
+ scope(config, run_id) {
101
+ const scope = `${config.site}/${config.room}/${run_id}`;
102
+ const previous = this.db.prepare("SELECT value FROM metadata WHERE key='scope'").get()?.value;
103
+ if (previous && previous !== scope) throw new Error('数据目录已绑定另一站点、房间或运行;请选择独立目录');
104
+ this.db.prepare("INSERT OR IGNORE INTO metadata VALUES ('scope',?)").run(scope);
105
+ }
106
+ /** Do not silently reuse a conversation after its software project or cwd changes. */
107
+ context(config, allow_codex_switch = false) {
108
+ const entries = Object.entries(config.participants).sort(([left], [right]) => left.localeCompare(right))
109
+ .map(([name, item]) => [name, item.adapter, item.project_id || '', item.project_name || '',
110
+ item.cwd ? resolve(item.cwd) : '', item.workdir ? resolve(item.workdir) : '', item.session_id || '']);
111
+ const context = JSON.stringify(entries);
112
+ const previous = this.db.prepare("SELECT value FROM metadata WHERE key='context'").get()?.value;
113
+ if (previous === context) return;
114
+ if (previous && previous !== context) {
115
+ const before = JSON.parse(previous);
116
+ const allowed = allow_codex_switch && before.length === entries.length && before.every((item, index) => {
117
+ const next = entries[index];
118
+ if (JSON.stringify(item) === JSON.stringify(next)) return true;
119
+ const session = this.binding(item[0]).session_id || item[6];
120
+ return item[0] === next[0] && item[1] !== next[1] && CODEX_ADAPTERS.includes(item[1])
121
+ && CODEX_ADAPTERS.includes(next[1]) && session && next[6] === session && (!item[6] || item[6] === session)
122
+ && JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
123
+ });
124
+ if (!allowed) throw new Error('已绑定会话的项目、cwd 或身份配置发生变化;请暂停并使用独立运行目录重新接入');
125
+ }
126
+ this.db.prepare("INSERT INTO metadata VALUES ('context',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(context);
127
+ }
128
+ /** Persist only known internal fields, never dynamic SQL column names. */
129
+ record(id, fields) {
130
+ const allowed = ['status', 'token', 'sent_at', 'last_renew', 'error', 'attempts', 'retry_at', 'receipt'];
131
+ const entries = Object.entries(fields);
132
+ if (entries.some(([key]) => !allowed.includes(key))) throw new Error('无效的内部状态字段');
133
+ this.db.prepare(`UPDATE inbox SET ${entries.map(([key]) => `${key}=?`).join(',')} WHERE id=?`)
134
+ .run(...entries.map(([, value]) => value), id);
135
+ }
136
+ /** Resolve actual session bindings without reading unrelated conversations. */
137
+ binding(participant) { return this.db.prepare('SELECT session_id FROM bindings WHERE participant=?').get(participant) || {}; }
138
+ /** Commit a returned session before later notifications can use it. */
139
+ bind(participant, session_id) { this.db.prepare('INSERT INTO bindings VALUES (?,?) ON CONFLICT(participant) DO UPDATE SET session_id=excluded.session_id').run(participant, session_id); }
140
+ /** List work that still needs dispatch or reconciliation. */
141
+ pending() { return this.db.prepare("SELECT * FROM inbox WHERE status NOT IN ('completed','cancelled','failed') ORDER BY id").all(); }
142
+ /** Release this instance's database connection. */
143
+ close() { this.db.close(); }
144
+ }
145
+
146
+ /** Hold one PID lock per runtime directory, without taking over a live owner. */
147
+ export function acquire_lock(directory) {
148
+ mkdirSync(directory, { recursive: true });
149
+ const path = join(directory, 'runner.lock');
150
+ if (existsSync(path)) {
151
+ const pid = Number(readFileSync(path, 'utf8'));
152
+ let alive = true;
153
+ try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
154
+ if (alive) throw new Error('此目录已有通知程序运行;不重复启动');
155
+ unlinkSync(path);
156
+ }
157
+ writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
158
+ return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
159
+ }
160
+
161
+ /** Write a non-secret task separately from its CLI-only credential file. */
162
+ export function task_files(config, event, token, binding) {
163
+ const id = `event-${event.id}`;
164
+ const draft_directory = config.draft_directory || `${config.directory}-drafts`;
165
+ mkdirSync(draft_directory, { recursive: true });
166
+ const access_path = join(config.directory, `${id}.access.json`);
167
+ const task_path = join(config.directory, `${id}.task.json`);
168
+ atomic_json(access_path, { site: config.site, room: config.room, token, session_id: binding.session_id || '' });
169
+ const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
170
+ if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
171
+ const adapter = config.participants[event.participant];
172
+ const auto_enter = AUTO_ENTER_ADAPTERS.has(adapter.adapter);
173
+ const skills_directory = join(config.project, '.agents', 'skills');
174
+ // Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
175
+ const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
176
+ const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
177
+ : adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
178
+ atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
179
+ project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
180
+ draft_directory,
181
+ role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
182
+ instructions: `读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。` });
183
+ const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。`;
184
+ const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。`;
185
+ const prompt = auto_enter
186
+ ? `${intro}接入程序已为本次执行完成 execution enter,不要再执行。任务内容如下(同一份保存在 ${task_path},无需再读):
187
+ ${readFileSync(task_path, 'utf8')}
188
+ 请直接读取指定版本的技能和网站现状。${scope}实际会话 ID 已由接入程序写入凭据文件。`
189
+ : `${intro}先读取任务文件:${task_path},立即用 ${cli} execution enter 进入,再读取指定版本的技能和网站现状。${scope}实际会话 ID 由接入程序写入凭据文件,尚未就绪只短暂重查,不编造。`;
190
+ return { id, participant: event.participant, directory: config.directory, draft_directory, skills_directory, access_path, prompt };
191
+ }
192
+
193
+ /** A running dispatcher retains only the processes it created itself. */
194
+ export class DiscussionRunner {
195
+ constructor(config, store, deliver = deliver_task) {
196
+ this.config = config;
197
+ this.store = store;
198
+ this.deliver = deliver;
199
+ this.children = new Set();
200
+ this.exited = new Set();
201
+ this.running = new Map();
202
+ this.seen = new Set();
203
+ this.blocked = new Set();
204
+ }
205
+ /** Append one timestamped stage to the event's trace file; tracing must never affect delivery. */
206
+ trace(id, stage, detail = {}) {
207
+ try { appendFileSync(join(this.config.directory, `event-${id}.trace.jsonl`), JSON.stringify({ t: new Date().toISOString(), stage, ...detail }) + '\n', { mode: 0o600 }); }
208
+ catch { /* Diagnostics only. */ }
209
+ }
210
+ /** Reserve with a durable token so a lost HTTP response can be reconciled. */
211
+ async dispatch(row, event) {
212
+ const token = row.token || randomUUID();
213
+ this.store.record(row.id, { token, status: 'reserving' });
214
+ this.trace(row.id, 'reserve_start');
215
+ await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
216
+ this.trace(row.id, 'reserve_done');
217
+ await this.deliver_reserved({ ...row, token }, event);
218
+ }
219
+ /** Retry only proven non-delivery, keeping the event, token and task unchanged. */
220
+ async deliver_reserved(row, event) {
221
+ const adapter = this.config.participants[event.participant];
222
+ if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
223
+ const binding = this.store.binding(event.participant);
224
+ if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
225
+ const attempts = row.attempts + 1;
226
+ this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
227
+ let invoked = false;
228
+ try {
229
+ const task = task_files(this.config, event, row.token, binding);
230
+ invoked = true;
231
+ const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
232
+ on_trace: (stage, detail) => this.trace(row.id, stage, detail),
233
+ on_binding: session => {
234
+ this.store.bind(event.participant, session);
235
+ if (AUTO_ENTER_ADAPTERS.has(adapter.adapter)) this.enter(row, session);
236
+ },
237
+ on_process: child => this.track(event.participant, child),
238
+ on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
239
+ on_failure: error => { try { this.store.record(row.id, { error }); } catch { /* Store may already be closed at shutdown. */ } },
240
+ on_exit: () => this.exited.add(row.id),
241
+ });
242
+ if (result.session_id) this.store.bind(event.participant, result.session_id);
243
+ this.store.record(row.id, { status: result.status, receipt: result.receipt || '' });
244
+ } catch (error) {
245
+ const not_sent = !invoked || error.delivery_status === 'not_sent';
246
+ this.store.record(row.id, { status: not_sent ? 'retry_wait' : 'uncertain', error: error.message,
247
+ retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
248
+ }
249
+ }
250
+ /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
251
+ async enter(row, session_id) {
252
+ try {
253
+ this.trace(row.id, 'enter_start');
254
+ await api(this.config, `/events/${row.id}`, { action: 'enter', session_id }, row.token);
255
+ this.trace(row.id, 'enter_done');
256
+ }
257
+ catch (error) { try { this.store.record(row.id, { error: `接入程序进入执行失败:${error.message}` }); } catch { /* Store may already be closed at shutdown. */ } }
258
+ }
259
+ /** Reconcile business evidence; accepted delivery alone never completes a task. */
260
+ async reconcile(row, event) {
261
+ if (['completed', 'cancelled', 'failed'].includes(event.status)) {
262
+ this.store.record(row.id, { status: event.status });
263
+ return;
264
+ }
265
+ // No app call occurs before dispatching is persisted. Reconcile a reserve response loss safely.
266
+ if (row.status === 'reserving') {
267
+ if (event.status === 'pending') return this.dispatch(row, event);
268
+ if (event.status === 'dispatched') {
269
+ const reserved = await api(this.config, `/events/${row.id}`, { action: 'view' }, row.token);
270
+ if (reserved.status !== 'dispatched') return this.reconcile(row, reserved);
271
+ return this.deliver_reserved(row, reserved);
272
+ }
273
+ }
274
+ if (row.status === 'retry_wait' && event.status === 'dispatched') {
275
+ if (row.attempts > DELIVERY_RETRY_DELAYS_MS.length) return this.fail(row, `投递 ${row.attempts} 次均确认未送出:${row.error}`);
276
+ if (Date.now() >= row.retry_at) await this.deliver_reserved(row, event);
277
+ return;
278
+ }
279
+ const adapter = this.config.participants[event.participant];
280
+ if (adapter?.adapter === 'antigravity' && event.status === 'dispatched') {
281
+ const receipt = sidecar_receipt(adapter, `event-${row.id}`);
282
+ if (receipt?.session_id) this.store.bind(event.participant, receipt.session_id);
283
+ if (receipt) this.store.record(row.id, { status: receipt.status, receipt: JSON.stringify(receipt), error: receipt.error || '' });
284
+ }
285
+ if (row.error && ['accepted', 'started'].includes(row.status)) return this.fail(row, row.error);
286
+ if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
287
+ // A business write may have just completed the task; recheck before declaring a silent exit.
288
+ const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
289
+ if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果`);
290
+ return this.reconcile(row, latest);
291
+ }
292
+ if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
293
+ if (event.status === 'started' && Date.now() - row.last_renew >= RENEW_MS) {
294
+ try { await api(this.config, `/events/${row.id}`, { action: 'renew' }); }
295
+ catch (error) {
296
+ if (/HTTP (403|409)/.test(error.message)) return this.fail(row, '执行时限已到;请核对原会话');
297
+ throw error;
298
+ }
299
+ this.store.record(row.id, { status: 'started', last_renew: Date.now(), error: '' });
300
+ }
301
+ }
302
+ /** Preserve the cause on the website and revoke work before asking for intervention. */
303
+ async fail(row, reason) {
304
+ await api(this.config, `/events/${row.id}`, { action: 'fail', reason });
305
+ this.store.record(row.id, { status: 'failed', error: reason });
306
+ this.stop_children();
307
+ }
308
+ /** Process one bounded iteration without waking models for empty checks. */
309
+ async tick() {
310
+ this.store.context(this.config);
311
+ let room;
312
+ try { room = await api(this.config, ''); }
313
+ catch (error) {
314
+ // Only a permanently deleted room drops its private agy projects; concluded or archived rooms keep them for later continuation.
315
+ if (/^HTTP 404: 房间不存在/.test(error.message)) remove_agy_projects(this.config.directory);
316
+ throw error;
317
+ }
318
+ if (room.limits?.reached && (!room.limits.closing || room.limits.reason === 'time_limit') && room.runtime.status === 'running') {
319
+ const generation = room.runtime.generation;
320
+ room = await api(this.config, '/limits', { actor: '用户' });
321
+ if (room.runtime.generation !== generation) this.stop_children();
322
+ }
323
+ this.room_title = room.title;
324
+ const run = room.runtime;
325
+ if (run.run_id) this.store.scope(this.config, run.run_id);
326
+ if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
327
+ this.stop_children();
328
+ if (run.expired) await api(this.config, '/runtime', { action: 'pause', revision: run.revision });
329
+ for (const row of this.store.pending()) {
330
+ const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
331
+ if (['completed', 'cancelled', 'failed'].includes(event.status)) this.store.record(row.id, { status: event.status });
332
+ }
333
+ // A paused automatic run waits so the web restart can resume it; stop, conclusion or manual mode ends the loop.
334
+ return run.mode === 'automatic' && (run.status === 'paused' || run.expired) ? 'waiting' : false;
335
+ }
336
+ this.store.receive(await api(this.config, `/events?after=${this.store.cursor()}`));
337
+ const rows = this.store.pending();
338
+ for (const row of rows.filter(item => item.status !== 'pending')) {
339
+ const event = await api(this.config, `/events/${row.id}`, { action: 'view' });
340
+ await this.reconcile(row, event);
341
+ }
342
+ if (this.store.pending().some(row => row.status !== 'pending')) return;
343
+ const current = room.current_turn;
344
+ const pending = rows.filter(row => row.status === 'pending').map(row => ({ row, event: JSON.parse(row.event) }));
345
+ const eligible = pending.filter(({ event }) => event.generation === run.generation && event.run_id === run.run_id
346
+ && (current && ['pending', 'notified'].includes(current.status) ? event.turn_id === current.id : event.role === 'host'));
347
+ for (const { row, event } of pending) {
348
+ if (event.generation !== run.generation || event.run_id !== run.run_id) this.store.record(row.id, { status: 'cancelled' });
349
+ }
350
+ for (const { row } of pending) if (!this.seen.has(row.id)) { this.seen.add(row.id); this.trace(row.id, 'event_seen'); }
351
+ const next = eligible.at(-1);
352
+ if (next && this.running.get(next.event.participant) && !this.blocked.has(next.row.id)) {
353
+ this.blocked.add(next.row.id);
354
+ this.trace(next.row.id, 'blocked_by_running_process', { participant: next.event.participant });
355
+ }
356
+ // A host that just saved the next turn may still be exiting; the same session cannot accept a second writer yet.
357
+ if (next && !this.running.get(next.event.participant)) {
358
+ const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
359
+ if (fresh.status === 'pending') {
360
+ await this.dispatch(next.row, fresh);
361
+ }
362
+ else this.store.record(next.row.id, { status: fresh.status });
363
+ }
364
+ }
365
+ /** Count live processes per participant so a resumed session never overlaps its previous writer. */
366
+ track(participant, child) {
367
+ this.children.add(child);
368
+ this.running.set(participant, (this.running.get(participant) || 0) + 1);
369
+ child.once('close', () => {
370
+ this.children.delete(child);
371
+ this.running.set(participant, this.running.get(participant) - 1);
372
+ });
373
+ }
374
+ /** Stop only child processes created by this runner, never the desktop apps. */
375
+ stop_children() { for (const child of this.children) child.kill(); }
376
+ }
377
+
378
+ /** Pause a running automatic run so the website never shows work nobody will consume. */
379
+ export async function pause_if_running(config) {
380
+ const latest = await api(config, '/runtime');
381
+ if (latest.status === 'running') await api(config, '/runtime', { action: 'pause', revision: latest.revision });
382
+ }
383
+
384
+ /** Run an explicitly configured room; install and status never start a discussion. */
385
+ export async function main(args = process.argv.slice(2)) {
386
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
387
+ config: { type: 'string' }, once: { type: 'boolean' }, minutes: { type: 'string' },
388
+ } });
389
+ if (!values.config || !['watch', 'start', 'status', 'pause', 'setup-sidecar'].includes(positionals[0])) {
390
+ throw new Error('用法: discussion_runner.mjs watch|start|status|pause|setup-sidecar --config 私有配置.json [--minutes 60] [--once]');
391
+ }
392
+ const config = load_config(values.config);
393
+ if (positionals[0] === 'setup-sidecar') {
394
+ for (const participant of Object.values(config.participants).filter(item => item.adapter === 'antigravity')) process.stdout.write(JSON.stringify(setup_sidecar(participant)) + '\n');
395
+ return;
396
+ }
397
+ const run = await api(config, '/runtime');
398
+ if (positionals[0] === 'status') { process.stdout.write(JSON.stringify(run) + '\n'); return; }
399
+ if (positionals[0] === 'pause') { await api(config, '/runtime', { action: 'pause', revision: run.revision }); return; }
400
+ const release = acquire_lock(config.directory);
401
+ const store = new DiscussionStore(config.directory);
402
+ const runner = new DiscussionRunner(config, store);
403
+ let stopped = false;
404
+ const stop = () => { stopped = true; };
405
+ process.once('SIGINT', stop);
406
+ process.once('SIGTERM', stop);
407
+ try {
408
+ store.context(config);
409
+ if (run.run_id) store.scope(config, run.run_id);
410
+ if (positionals[0] === 'start') {
411
+ const room = await api(config, '');
412
+ const minutes = Number(values.minutes ?? room.limits?.max_minutes ?? 0);
413
+ if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) throw new Error('总时长或 --minutes 须在 [0,1440],0 表示不限时长');
414
+ if (![room.host, ...Object.keys(room.experts)].every(name => config.participants[name])) throw new Error('尚未配置全部参与者的本机软件');
415
+ await api(config, '/runtime', { action: 'start', mode: 'automatic', revision: run.revision, deadline: minutes ? Math.floor(Date.now() / 1000 + minutes * 60) : 0 });
416
+ }
417
+ do {
418
+ try { if (await runner.tick() === false) break; }
419
+ catch (error) { process.stderr.write(JSON.stringify({ error: error.message }) + '\n'); if (values.once) throw error; }
420
+ if (!values.once && !stopped) await sleep(POLL_MS);
421
+ } while (!values.once && !stopped);
422
+ } finally {
423
+ if (stopped) {
424
+ try { await pause_if_running(config); }
425
+ catch (error) { process.stderr.write(`停止回执未确认;租约到期后拒绝写入:${error.message}\n`); }
426
+ }
427
+ runner.stop_children(); store.close(); release();
428
+ process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
429
+ }
430
+ }
431
+
432
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
433
+ main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
434
+ }
package/web/modern.js CHANGED
@@ -45521,7 +45521,7 @@ var nK = /*#__PURE__*/ nH({
45521
45521
  let e = n.value === "create" ? "create" : n.value === "edit" ? "save_topic" : "schedule";
45522
45522
  if (e === "create") {
45523
45523
  let r = t.targetFolder?.id ?? null, i = o.value.fields["new-room-mode"], a = i === "free" || o.value.launch.create && i === "automatic";
45524
- a || (n.value = ""), await l(e, r) && a && !o.value.launch.create && (n.value = "");
45524
+ a || (n.value = ""), await l(e, r) && a && (n.value = "");
45525
45525
  } else await l(e) && (n.value = "");
45526
45526
  }
45527
45527
  function ve(e) {