ai_co_discuss 0.2.97 → 0.2.99

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.
@@ -13,6 +13,10 @@ import { DiscussionRunner, DiscussionStore, acquire_lock, api, load_config, paus
13
13
  import { FreeRoomRunner } from './free_runner.mjs';
14
14
 
15
15
  const POLL_MS = 2000;
16
+ // A hosted site is polled less often than a local one: every request spends its database quota.
17
+ const REMOTE_POLL_MS = 10_000;
18
+ const UPDATE_RETRY_MS = 5000;
19
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
16
20
  const REMOTE_SYNC_MS = 3000;
17
21
  const START_WAIT_MS = 5000;
18
22
  const STOP_WAIT_MS = 20_000;
@@ -274,7 +278,7 @@ class RoomWorker {
274
278
  }
275
279
  this.state.config_version = snapshot.version;
276
280
  }
277
- /** A manual Claude or Codex expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
281
+ /** A manual Claude, Codex or agy expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
278
282
  async adopt_manual_members() {
279
283
  // A discussion runner lists members itself; a free room's registered manual members come from the room view.
280
284
  const members = this.config.mode === 'free' ? (await api(this.config, '')).free?.members || [] : this.runner.members || [];
@@ -344,32 +348,103 @@ export class DiscussionService {
344
348
  this.create_runner = create_runner;
345
349
  this.log = log;
346
350
  this.workers = new Map();
351
+ // How often each room is checked without a push: a local site costs nothing, a hosted one spends its database quota.
352
+ this.intervals = { local: POLL_MS, remote: REMOTE_POLL_MS };
353
+ // The resident service turns this on so a site's update stream checks its rooms at once; tests leave it off.
354
+ this.push = false;
355
+ this.streams = new Map();
356
+ this.closed = false;
347
357
  }
348
- /** Reload the registry, close removed rooms, then tick every registered room. */
349
- async tick() {
358
+ /**
359
+ * Reload the registry, close removed rooms, then tick the rooms that are due. `wait: true` ticks every idle room and
360
+ * waits for them; `wait: false` returns at once, so a slow remote site never delays the other rooms.
361
+ */
362
+ async tick({ wait = true } = {}) {
350
363
  const configs = read_registry(this.home);
351
364
  for (const [path, worker] of this.workers) {
352
- if (!configs.includes(path)) { await worker.close(true); this.workers.delete(path); }
365
+ if (!configs.includes(path)) { await worker.busy; await worker.close(true); this.workers.delete(path); }
353
366
  }
354
367
  for (const path of configs) {
355
368
  if (!this.workers.has(path)) this.workers.set(path, new RoomWorker(path, this.create_runner, this.log, this.home));
356
369
  const worker = this.workers.get(path);
357
- await worker.tick();
358
- if (!worker.removed) continue;
359
- // The room no longer exists on its website, so there is nothing to pause; drop the registration once.
360
- const directory = worker.config?.directory;
361
- await worker.close(false);
362
- this.workers.delete(path);
363
- try { unregister_room(this.home, path); } catch { /* Already removed by another command. */ }
364
- // Its runtime, task files and answer drafts serve no one now; a file still held is left to the periodic sweep.
365
- try { remove_room_files(this.home, path, directory); } catch (error) { this.log(`${worker.state.room || path}: 本机文件未能删除,稍后清理:${error.message}`); }
366
- this.log(`${worker.state.site || ''}/${worker.state.room || path}: 房间已在网站删除,已撤销登记并删除本机文件`);
370
+ if (!worker.busy && (wait || Date.now() >= (worker.next_at ?? 0))) this.start(path, worker);
367
371
  }
372
+ if (this.push) for (const site of new Set([...this.workers.values()].map(worker => worker.state.site).filter(Boolean))) this.watch(site);
373
+ if (wait) await Promise.all([...this.workers.values()].map(worker => worker.busy));
374
+ this.write_status();
375
+ }
376
+ /** Start one room's tick; a room asked again while busy ticks once more right after. */
377
+ start(path, worker) {
378
+ if (this.closed) return;
379
+ if (worker.busy) { worker.woken = true; return; }
380
+ // Nothing awaits this tick, so its failure is logged here instead of crashing the service.
381
+ worker.busy = this.tick_worker(path, worker).catch(error => this.log(`${worker.state.room || path}: 检查失败:${error.message}`))
382
+ .finally(() => {
383
+ // Counted from the end of the tick, once the room's site is known; a slow site does not poll back to back.
384
+ const local = !worker.state.site || LOOPBACK.has(new URL(worker.state.site).hostname);
385
+ worker.next_at = Date.now() + (local ? this.intervals.local : this.intervals.remote);
386
+ worker.busy = null;
387
+ if (worker.woken && this.workers.get(path) === worker) { worker.woken = false; this.start(path, worker); }
388
+ });
389
+ }
390
+ /** Check every room of a site now, as its website reported a change. */
391
+ wake(site) {
392
+ for (const [path, worker] of this.workers) if (worker.state.site === site) this.start(path, worker);
393
+ }
394
+ /**
395
+ * Follow a site's `/api/updates` stream and wake its rooms on each change, so a message reaches the AI within
396
+ * milliseconds instead of the next poll. A site without push (204) is left to polling; a dropped stream reconnects.
397
+ */
398
+ watch(site) {
399
+ if (this.streams.has(site) || this.closed) return;
400
+ const controller = new AbortController();
401
+ const done = (async () => {
402
+ while (!controller.signal.aborted) {
403
+ try {
404
+ const response = await fetch(`${site}/api/updates`, { signal: controller.signal, headers: { accept: 'text/event-stream' } });
405
+ if (response.status === 204) return;
406
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
407
+ const decoder = new TextDecoder();
408
+ let buffer = '';
409
+ for await (const chunk of response.body) {
410
+ buffer += decoder.decode(chunk, { stream: true }).replaceAll('\r\n', '\n');
411
+ for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) {
412
+ // `ready` comes on every (re)connection, so changes missed while disconnected are picked up too.
413
+ if (/^event: (change|ready)$/m.test(buffer.slice(0, end))) this.wake(site);
414
+ buffer = buffer.slice(end + 2);
415
+ }
416
+ }
417
+ } catch { /* Reconnect below; polling covers the gap. */ }
418
+ await sleep(UPDATE_RETRY_MS, undefined, { signal: controller.signal }).catch(() => {});
419
+ }
420
+ })();
421
+ this.streams.set(site, { controller, done });
422
+ }
423
+ /** Publish every room's latest state for `codiscuss status` and the website's service check. */
424
+ write_status() {
368
425
  atomic_json(join(this.home, 'status.json'), { pid: process.pid, updated_at: Math.floor(Date.now() / 1000),
369
426
  rooms: [...this.workers.values()].map(worker => worker.state) });
370
427
  }
371
- /** Pause running rooms and release every lock and child process. */
428
+ /** Tick one room; a room deleted on its website is unregistered here. */
429
+ async tick_worker(path, worker) {
430
+ await worker.tick();
431
+ if (!worker.removed) return this.write_status();
432
+ // The room no longer exists on its website, so there is nothing to pause; drop the registration once.
433
+ const directory = worker.config?.directory;
434
+ await worker.close(false);
435
+ this.workers.delete(path);
436
+ try { unregister_room(this.home, path); } catch { /* Already removed by another command. */ }
437
+ // Its runtime, task files and answer drafts serve no one now; a file still held is left to the periodic sweep.
438
+ try { remove_room_files(this.home, path, directory); } catch (error) { this.log(`${worker.state.room || path}: 本机文件未能删除,稍后清理:${error.message}`); }
439
+ this.log(`${worker.state.site || ''}/${worker.state.room || path}: 房间已在网站删除,已撤销登记并删除本机文件`);
440
+ }
441
+ /** Pause running rooms and release every lock and child process, once their current ticks finish. */
372
442
  async close() {
443
+ this.closed = true;
444
+ for (const stream of this.streams.values()) stream.controller.abort();
445
+ await Promise.all([...this.streams.values()].map(stream => stream.done));
446
+ this.streams.clear();
447
+ await Promise.all([...this.workers.values()].map(worker => worker.busy));
373
448
  for (const worker of this.workers.values()) await worker.close(true);
374
449
  this.workers.clear();
375
450
  }
@@ -460,9 +535,10 @@ export async function run_service(home, output = () => {}) {
460
535
  output(line);
461
536
  };
462
537
  const service = new DiscussionService(home, undefined, log_line);
538
+ service.push = true;
463
539
  const stop_path = join(home, 'stop.request');
464
540
  if (existsSync(stop_path)) unlinkSync(stop_path);
465
- let stopped = false;
541
+ let stopped = false, syncing = null;
466
542
  const stop = () => { stopped = true; };
467
543
  process.once('SIGINT', stop);
468
544
  process.once('SIGTERM', stop);
@@ -474,7 +550,7 @@ export async function run_service(home, output = () => {}) {
474
550
  const { sync_remote_launches } = await import('./remote_launch.mjs');
475
551
  let synced_at = 0, swept_at = 0;
476
552
  while (!stopped && !existsSync(stop_path)) {
477
- try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
553
+ try { await service.tick({ wait: false }); } catch (error) { log_line(`服务检查失败:${error.message}`); }
478
554
  if (Date.now() - swept_at >= SWEEP_MS) {
479
555
  swept_at = Date.now();
480
556
  try {
@@ -484,13 +560,16 @@ export async function run_service(home, output = () => {}) {
484
560
  }
485
561
  } catch (error) { log_line(`清理本机文件失败:${error.message}`); }
486
562
  }
487
- if (Date.now() - synced_at >= REMOTE_SYNC_MS) {
563
+ // Runs beside the room ticks: a slow remote site must not hold up local rooms.
564
+ if (!syncing && Date.now() - synced_at >= REMOTE_SYNC_MS) {
488
565
  synced_at = Date.now();
489
- try { await sync_remote_launches(home, { log: log_line }); } catch (error) { log_line(`远程启动检查失败:${error.message}`); }
566
+ syncing = sync_remote_launches(home, { log: log_line }).catch(error => log_line(`远程启动检查失败:${error.message}`))
567
+ .finally(() => { syncing = null; });
490
568
  }
491
569
  await sleep(POLL_MS);
492
570
  }
493
571
  } finally {
572
+ await syncing;
494
573
  await service.close();
495
574
  if (existsSync(stop_path)) unlinkSync(stop_path);
496
575
  log_line('讨论服务已停止');
@@ -33,8 +33,92 @@ export function expert_prompt(profile = {}) {
33
33
  return String((own ? profile.system_prompt : EXPERT_PROMPT_PRESETS[profile.adapter]) || '').trim();
34
34
  }
35
35
 
36
+ /** The line that introduces the member's own prompt after the fixed task text; the prompt dialog shows the same line. */
37
+ export const EXPERT_PROMPT_HEADING = '用户为你单独设置的提示(补充做法与偏好,不改变权限与技能规则):';
38
+
36
39
  /** Append the member's prompt to a task prompt, marked as the user's own guidance for this member. */
37
40
  export function with_expert_prompt(prompt, profile) {
38
41
  const extra = expert_prompt(profile);
39
- return extra ? `${prompt}\n\n用户为你单独设置的提示(补充做法与偏好,不改变权限与技能规则):\n${extra}` : prompt;
42
+ return extra ? `${prompt}\n\n${EXPERT_PROMPT_HEADING}\n${extra}` : prompt;
43
+ }
44
+
45
+ /*
46
+ * The fixed task text the runner adds for a member woken on this computer. It lives here, free of Node modules, so the
47
+ * expert prompt dialog shows the same text the runner sends, with placeholders for the values known only at delivery.
48
+ */
49
+
50
+ /**
51
+ * Project access of a member woken on this computer: 只读, 可写 (project files and any command), 自动 (the software's own
52
+ * automatic approval) and 完全放开 (no approval or sandbox). A manual member the service took over is `own`.
53
+ */
54
+ export const PROJECT_ACCESS = ['read', 'write', 'auto', 'full'];
55
+
56
+ const ACCESS_LABELS = { read: '只读', write: '可写', auto: '自动', full: '完全放开' };
57
+
58
+ /** What each level does in each software, measured headless (Claude Code 2.1.312, Codex CLI 0.163, agy 1.4.10). */
59
+ const ACCESS_HINTS = {
60
+ claude: { read: '只放行网站命令与草稿,不改项目文件、不跑其他命令', write: '可改项目文件,Bash / PowerShell 命令全部放行(无沙箱)',
61
+ auto: 'Claude auto 模式:由分类器判断,实测项目外写入和联网都会放行', full: 'bypassPermissions:跳过全部权限检查' },
62
+ codex_cli: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
63
+ auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
64
+ antigravity_cli: { read: '只放行网站命令;碰到未放行的命令会直接结束本轮', write: '可写项目,命令全部放行(在 agy 沙箱内)',
65
+ full: '再放开沙箱外命令与 MCP 工具' },
66
+ };
67
+
68
+ /** agy has no automatic mode headless ("not available for this account", tested 1.4.10), so it offers no 自动. */
69
+ export function access_levels(adapter) {
70
+ return adapter === 'antigravity_cli' ? PROJECT_ACCESS.filter(level => level !== 'auto') : PROJECT_ACCESS;
71
+ }
72
+
73
+ /** The levels a software supports, labelled for the page, with what each does there on hover. */
74
+ export function access_options(adapter) {
75
+ return access_levels(adapter).map(value => ({ value, label: ACCESS_LABELS[value], hint: ACCESS_HINTS[adapter]?.[value] || '' }));
76
+ }
77
+
78
+ /** The page's name for a level; `own` reads as decided by the member's own session. */
79
+ export function access_label(level) { return level === 'own' ? '自行决定' : ACCESS_LABELS[level] || '只读'; }
80
+
81
+ /**
82
+ * Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project.
83
+ * A taken-over manual member (`own`) keeps its own session's permissions, so the room's limits do not apply.
84
+ */
85
+ export function runs_commands(config) {
86
+ const access = config.project_access;
87
+ return ['own', 'auto', 'full'].includes(access) || (access === 'write' && Boolean(config.cwd || config.workdir));
88
+ }
89
+
90
+ /** The task text's rule for project files and commands; `purpose` says what the member works for (按请求 / 按议题需要). */
91
+ export function project_rule(profile, purpose) {
92
+ if (profile.project_access === 'own') return '项目文件与命令权限沿用你自己会话的软件设置。';
93
+ if (profile.project_access === 'auto') return `由软件自动审批:可${purpose}修改项目文件并运行所需命令;被沙箱或审批拦下时按软件方式申请放行(Codex 申请提权),仍被拒绝就在回答中说明。`;
94
+ if (profile.project_access === 'full') return `已跳过软件审批与沙箱,可${purpose}修改文件并运行所需命令。`;
95
+ if (!profile.cwd) return '未指定项目目录。';
96
+ return profile.project_access === 'write' ? `用户允许${purpose}修改 cwd 中的项目文件并运行所需命令。` : 'cwd 中的项目文件只读,不修改。';
97
+ }
98
+
99
+ /** A read-only agy project allows only codiscuss commands and headless agy ends the turn on a denied one, so every such agy task states it; no member prompt can drop it. */
100
+ export function agy_command_rule(adapter, cli, config = {}) {
101
+ return adapter === 'antigravity_cli' && !runs_commands(config) ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;列目录、搜索或读取文件用 list_dir、find_by_name、grep_search、view_file。` : '';
102
+ }
103
+
104
+ /** Windows PowerShell mis-decodes UTF-8 files; the runner adds this where the member's commands can run rg. */
105
+ export const READ_RULE = '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。';
106
+
107
+ /**
108
+ * A free-room task's fixed text: `instructions` saved in the task file and `prompt` sent with each wake-up, followed by
109
+ * the member's own prompt. `discussion` is a direct question asked while a discussion runs in the room.
110
+ */
111
+ export function free_task_text({ name, role, discussion, profile, cli, room, messages, read_after, execution_id, task_path, read_rule = '', inbox = false }) {
112
+ // A task posted into a member's own open window has no process environment of ours, so it names the credential file.
113
+ const bound_access = !inbox && ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
114
+ const access_arg = bound_access ? '' : ' --access-file 凭据路径';
115
+ const access_rule = bound_access ? '本次凭据文件已通过进程环境 DISCUSS_ACCESS_FILE 绑定,codiscuss 自动读取,命令无需 --access-file。' : '';
116
+ const files_rule = project_rule(profile, '按请求');
117
+ // The host routes unnamed messages and receives hand-backs; it may name members in its answer.
118
+ const host_rule = role === 'host' && !discussion
119
+ ? `你是本房间的主持人:按任务 skills 中 discuss-host 的自由协作主持说明处理 route、handoff 与 all 请求。先用 ${cli} room view ${room} 查看成员与状态;派活在回答中加 --to 成员名(不能点名自己),只点名一位且还要它交回时加 --handoff。`
120
+ : '';
121
+ const instructions = `${agy_command_rule(profile.adapter, cli, profile)}${discussion ? '讨论房间:用户直接向你提问(' + messages + ')。这不是你的发言安排,不要领取发言权、不要安排他人,也不改变讨论进度,只回答这个提问。' : '自由协作房间:用户点名了你(' + messages + ')。'}${access_rule}先用 ${cli} message list ${room} --after ${read_after}${access_arg} 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${room} --as ${name} --kind chat --execution ${execution_id} --body-file 草稿 --submission-id 新UUID${access_arg} 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${files_rule}不读取或打印凭据内容,不建定时任务。`;
122
+ const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution_id} 提交一次回答后结束。${runs_commands(profile) ? '网站读写' : '命令'}只用任务内 subcommands 列出的 ${cli} 子命令;${files_rule}不读取凭据内容。${read_rule}`;
123
+ return { instructions, prompt };
40
124
  }
@@ -3,9 +3,10 @@ import { spawnSync } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { basename, join, resolve } from 'node:path';
6
- import { agy_command_rule, atomic_json, deliver_task, SegmentLog, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
7
- import { api, CODEX_ADAPTERS } from './discussion_runner.mjs';
8
- import { with_expert_prompt } from './expert_prompts.mjs';
6
+ import { atomic_json, claude_inbox_ready, deliver_task, SegmentLog, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
7
+ import { api, CODEX_ADAPTERS, read_rule } from './discussion_runner.mjs';
8
+ import { free_task_text, with_expert_prompt } from './expert_prompts.mjs';
9
+ import { BRANCHABLE_ADAPTERS, branch_session, session_exists } from './session_cut.mjs';
9
10
  import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, route_free_message } from './scheduler.mjs';
10
11
 
11
12
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -68,17 +69,11 @@ export function free_task_files(config, member, execution, binding) {
68
69
  atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
69
70
  session_id: binding.session_id || '' });
70
71
  const cli = process.platform === 'win32' && profile.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
71
- const bound_access = ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
72
- const access_arg = bound_access ? '' : ' --access-file 凭据路径';
73
- const access_rule = bound_access ? '本次凭据文件已通过进程环境 DISCUSS_ACCESS_FILE 绑定,codiscuss 自动读取,命令无需 --access-file。' : '';
74
- const project_rule = !profile.cwd ? '未指定项目目录。'
75
- : profile.project_access === 'write' ? '用户允许按请求修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
72
+ const inbox = claude_inbox_ready(profile, binding);
73
+ const bound_access = !inbox && ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
76
74
  const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
77
- // The host routes unnamed messages and receives hand-backs; it may name members in its answer.
78
- const discussion = config.mode !== 'free';
79
- const host_rule = member.role === 'host' && !discussion
80
- ? `你是本房间的主持人:按任务 skills 中 discuss-host 的自由协作主持说明处理 route、handoff 与 all 请求。先用 ${cli} room view ${config.room} 查看成员与状态;派活在回答中加 --to 成员名(不能点名自己),只点名一位且还要它交回时加 --handoff。`
81
- : '';
75
+ const { instructions, prompt } = free_task_text({ name: member.name, role: member.role, discussion: config.mode !== 'free', profile, cli,
76
+ room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path, read_rule: read_rule(profile.adapter), inbox });
82
77
  atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
83
78
  execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
84
79
  request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
@@ -87,11 +82,9 @@ export function free_task_files(config, member, execution, binding) {
87
82
  skills: [...['discuss-participant/SKILL.md', 'discuss-participant/references/free.md'],
88
83
  ...member.role === 'host' ? ['discuss-host/SKILL.md', 'discuss-host/references/free.md'] : []].map(file => join(config.project, '.agents/skills', file)),
89
84
  cli, subcommands: TASK_SUBCOMMANDS,
90
- ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }),
91
- instructions: `${agy_command_rule(profile.adapter, cli)}${discussion ? '讨论房间:用户直接向你提问(' + messages + ')。这不是你的发言安排,不要领取发言权、不要安排他人,也不改变讨论进度,只回答这个提问。' : '自由协作房间:用户点名了你(' + messages + ')。'}${access_rule}先用 ${cli} message list ${config.room} --after ${execution.read_until}${access_arg} 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID${access_arg} 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
92
- const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
85
+ ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }), instructions });
93
86
  return { id, participant: member.name, directory: config.directory, draft_directory,
94
- skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile) };
87
+ skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile), inbox };
95
88
  }
96
89
 
97
90
  const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
@@ -100,8 +93,8 @@ const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Cl
100
93
  /** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
101
94
  function member_description(name, profile, work) {
102
95
  const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
103
- const access = profile.adapter === 'api' ? '' : !profile.cwd ? ',未指定项目目录'
104
- : profile.project_access === 'write' ? `,可修改项目 ${project} 的文件` : `,项目 ${project} 只读`;
96
+ const access = profile.adapter === 'api' ? '' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
97
+ : profile.project_access === 'read' ? `,项目 ${project} 只读` : `,可修改项目 ${project} 的文件并运行命令`;
105
98
  return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
106
99
  }
107
100
 
@@ -346,12 +339,15 @@ export class FreeRoomRunner {
346
339
  const before = workspace_snapshot(directory);
347
340
  if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
348
341
  const binding = this.store.binding(member.name);
349
- if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
342
+ // A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
343
+ if (binding.session_id === undefined && adapter.session_id) binding.session_id = adapter.session_id;
350
344
  let invoked = false;
351
345
  try {
346
+ const warning = await this.cut_rewound(member.name, adapter, binding, execution);
352
347
  const task = free_task_files(this.config, member, execution, binding);
353
348
  invoked = true;
354
349
  this.segments.start(execution.id);
350
+ if (warning) this.note(execution.id, warning);
355
351
  const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
356
352
  api_context: { site: this.config.site, room: this.config.room, token: this.config.members[member.name].token,
357
353
  home: this.config.api_home, mode: 'free', role: member.role || 'member', execution_id: execution.id,
@@ -364,6 +360,8 @@ export class FreeRoomRunner {
364
360
  // A stopped process may report after the next execution started; only its own execution takes the result.
365
361
  on_failure: error => { try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ } },
366
362
  on_exit: denial => this.exited.set(execution.id, denial || ''),
363
+ // Shown beside the answer or failure so the owner can allow them; a lost report only hides the list.
364
+ on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
367
365
  });
368
366
  if (result.session_id) this.store.bind(member.name, result.session_id);
369
367
  this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
@@ -376,6 +374,26 @@ export class FreeRoomRunner {
376
374
  this.save(member.name, { status: 'idle', attempts, error: error.message });
377
375
  }
378
376
  }
377
+ /**
378
+ * Before a member works again after a rewind, branch its session before the executions the rewind took back.
379
+ * A Claude fork happens on its next turn, so it stays pending until that session exists. Returns a warning when the
380
+ * session could not be cut and the member continues in the old one.
381
+ */
382
+ async cut_rewound(name, adapter, binding, execution) {
383
+ const key = `session-fork:${name}`;
384
+ const pending = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
385
+ if (pending && !session_exists(adapter.adapter, binding.session_id)) { binding.fork = JSON.parse(pending); return ''; }
386
+ if (pending) this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
387
+ if (!binding.session_id || !execution.rewound_executions?.length || !BRANCHABLE_ADAPTERS.includes(adapter.adapter)) return '';
388
+ try {
389
+ const cut = await branch_session(adapter, binding.session_id, execution.rewound_executions);
390
+ if (!cut) return '';
391
+ if (cut.fork) this.store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, JSON.stringify(cut.fork));
392
+ this.store.bind(name, cut.session_id);
393
+ Object.assign(binding, cut);
394
+ return '';
395
+ } catch (error) { return `会话未能回退(${error.message}),本次沿用原会话,它可能仍记得已回退的内容`; }
396
+ }
379
397
  /** Follow the website's verdict; stop only this member's processes when its execution ends. */
380
398
  async reconcile(member, row, active, minutes) {
381
399
  if (row.status === 'reserving') return this.reserve(member, row);
@@ -12,7 +12,7 @@ export const ROUTE_CONFIDENCE = 0.7;
12
12
  /** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
13
13
  export const ROUTE_WIDE_CONFIDENCE = 0.85;
14
14
  /** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
15
- export const ROUTE_PROMPT_VERSION = 5;
15
+ export const ROUTE_PROMPT_VERSION = 6;
16
16
  const HOST_CHOICE = '__host__';
17
17
  const NOTE_CHOICE = '__note__';
18
18
  const ALL_CHOICE = '__all__';
@@ -148,11 +148,11 @@ export function certainty(answer) {
148
148
  */
149
149
  export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
150
150
  const choices = [...members.map(member => ({ value: member.name, description: member.description })),
151
- { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或需要分工、排先后、互相回应` },
152
- { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合;须是用户明确要求大家各自回应,如“大家/都出来打个招呼”“各自介绍一下”“各说一句看法”' },
151
+ { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
152
+ { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
153
153
  { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
154
154
  const questions = [{ type: 'choice', name: 'handler', choices,
155
- instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)明确要求所有成员各自回应一次(用了“大家”“都”“各自”等)选全体;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
155
+ instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)符合“全体”选项的条件时选全体;只出现“大家”“都”“各自”“一起”等字眼不够;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。' }];
156
156
  const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
157
157
  const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
158
158
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Branch a member's software session so a rewound or forked room continues from an earlier point. The original
3
+ * session is never changed: Claude forks through its own CLI on the next turn (--fork-session), Codex through its app
4
+ * server's thread/fork, and agy by copying its conversation database. Each execution's prompt names its task file
5
+ * (`free-<id>.task.json`), which marks where that execution entered the session.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { createInterface } from 'node:readline';
13
+ import { DatabaseSync } from 'node:sqlite';
14
+
15
+ const FORK_TIMEOUT_MS = 60_000;
16
+ // Software whose session this machine can branch; the website lists taken-back executions only for these members.
17
+ export const BRANCHABLE_ADAPTERS = ['claude', 'codex', 'codex_cli', 'antigravity_cli'];
18
+
19
+ /** The task file name an execution's prompt carries. */
20
+ export const execution_marker = id => `free-${id}.task.json`;
21
+
22
+ /** Where each software keeps one session, or '' when this machine has none by that ID. */
23
+ function session_path(adapter, session_id, env, home) {
24
+ if (adapter === 'claude') {
25
+ const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
26
+ if (!existsSync(projects)) return '';
27
+ return readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync) || '';
28
+ }
29
+ if (adapter === 'codex_cli') {
30
+ const sessions = join(env.CODEX_HOME || join(home, '.codex'), 'sessions');
31
+ if (!existsSync(sessions)) return '';
32
+ const found = readdirSync(sessions, { recursive: true }).map(String).find(path => path.endsWith(`-${session_id}.jsonl`));
33
+ return found ? join(sessions, found) : '';
34
+ }
35
+ const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
36
+ return existsSync(file) ? file : '';
37
+ }
38
+
39
+ /** Whether a session exists on this machine, e.g. to tell if a pending Claude fork already happened. */
40
+ export function session_exists(adapter, session_id, { env = process.env, home = homedir() } = {}) {
41
+ return Boolean(session_id && session_path(adapter, session_id, env, home));
42
+ }
43
+
44
+ /** JSON lines of a session log; lines the software wrote in another shape are skipped. */
45
+ function log_entries(file) {
46
+ return readFileSync(file, 'utf8').split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
47
+ }
48
+
49
+ /** The text of a prompt as the software stored it: a string, or text parts (tool results carry no prompt). */
50
+ function prompt_text(content) {
51
+ if (typeof content === 'string') return content;
52
+ return Array.isArray(content) ? content.filter(part => ['text', 'input_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
53
+ }
54
+
55
+ const marked = (text, markers) => markers.some(marker => text.includes(marker));
56
+
57
+ /** Claude: fork on the next turn at the last answer before the first marked prompt; a cut before any answer starts anew. */
58
+ function claude_branch(session_id, markers, always, env, home) {
59
+ const file = session_path('claude', session_id, env, home);
60
+ if (!file) throw new Error(`本机找不到 Claude 会话 ${session_id}`);
61
+ const entries = log_entries(file);
62
+ const index = entries.findIndex(entry => entry.type === 'user' && marked(prompt_text(entry.message?.content), markers));
63
+ if (index < 0) return always ? { session_id: randomUUID(), fork: { from: session_id, at: '' } } : null;
64
+ const at = entries.slice(0, index).findLast(entry => entry.type === 'assistant' && entry.uuid)?.uuid;
65
+ return at ? { session_id: randomUUID(), fork: { from: session_id, at } } : { session_id: '' };
66
+ }
67
+
68
+ /** Codex turns in order, and the index of the first turn whose user prompt carries a marker (-1 when none does). */
69
+ function codex_turns(file, markers) {
70
+ const turns = [];
71
+ let cut = -1;
72
+ for (const entry of log_entries(file)) {
73
+ const payload = entry.payload || {};
74
+ if (entry.type === 'event_msg' && payload.type === 'task_started' && payload.turn_id) turns.push(payload.turn_id);
75
+ else if (cut < 0 && turns.length && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user'
76
+ && marked(prompt_text(payload.content), markers)) cut = turns.length - 1;
77
+ }
78
+ return { turns, cut };
79
+ }
80
+
81
+ /** Ask Codex's app server to fork a thread, through `last_turn` when given; returns the new thread's ID. */
82
+ function codex_fork(profile, thread_id, last_turn, spawn_process) {
83
+ return new Promise((resolve_fork, reject) => {
84
+ const child = spawn_process(profile.executable, ['app-server'], { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
85
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
86
+ let settled = false;
87
+ const finish = (error, value) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
91
+ if (error) reject(error); else resolve_fork(value);
92
+ };
93
+ const timer = setTimeout(() => finish(new Error('Codex 分叉会话超时')), FORK_TIMEOUT_MS);
94
+ const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
95
+ child.once('error', error => finish(error));
96
+ child.once('close', () => finish(new Error('Codex app-server 未完成分叉就退出了')));
97
+ child.stdin.on('error', error => finish(error));
98
+ child.stderr.resume();
99
+ lines.on('line', line => {
100
+ let reply;
101
+ try { reply = JSON.parse(line); } catch { return; }
102
+ if (reply.error && [1, 2].includes(reply.id)) return finish(new Error(`Codex 分叉会话失败:${reply.error.message}`));
103
+ if (reply.id === 1) {
104
+ send({ method: 'initialized', params: {} });
105
+ send({ id: 2, method: 'thread/fork', params: { threadId: thread_id, excludeTurns: true, ...(last_turn ? { lastTurnId: last_turn } : {}) } });
106
+ } else if (reply.id === 2) {
107
+ const id = reply.result?.thread?.id;
108
+ finish(id ? null : new Error('Codex 分叉会话没有返回新会话 ID'), id);
109
+ }
110
+ });
111
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'codiscuss', version: '0.1.0' } } });
112
+ });
113
+ }
114
+
115
+ /** Codex: fork through the turn before the first marked one; a cut at the first turn starts anew. */
116
+ async function codex_branch(profile, session_id, markers, always, env, home, spawn_process) {
117
+ const file = session_path('codex_cli', session_id, env, home);
118
+ if (!file) throw new Error(`本机找不到 Codex 会话 ${session_id}`);
119
+ const { turns, cut } = codex_turns(file, markers);
120
+ if (cut < 0 && !always) return null;
121
+ if (cut === 0) return { session_id: '' };
122
+ return { session_id: await codex_fork(profile, session_id, cut > 0 ? turns[cut - 1] : '', spawn_process) };
123
+ }
124
+
125
+ /** agy: copy the conversation database under a new ID, without the steps from the first marked one on. */
126
+ function agy_branch(session_id, markers, always, home) {
127
+ const file = session_path('antigravity_cli', session_id, process.env, home);
128
+ if (!file) throw new Error(`本机找不到 agy 会话 ${session_id}`);
129
+ const encoded = markers.map(marker => Buffer.from(marker, 'utf8'));
130
+ const source = new DatabaseSync(file, { readOnly: true });
131
+ const target = join(file, '..', `${randomUUID()}.db`);
132
+ let cut;
133
+ try {
134
+ // The prompt is stored inside the step's protobuf payload, as UTF-8 text.
135
+ cut = source.prepare('SELECT idx,step_payload FROM steps ORDER BY idx').all()
136
+ .find(row => row.step_payload && encoded.some(marker => Buffer.from(row.step_payload).includes(marker)))?.idx;
137
+ if (cut === undefined && !always) return null;
138
+ if (cut === 0) return { session_id: '' };
139
+ source.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
140
+ } finally { source.close(); }
141
+ const id = target.slice(-39, -3);
142
+ const copy = new DatabaseSync(target);
143
+ try {
144
+ if (cut !== undefined) copy.prepare('DELETE FROM steps WHERE idx>=?').run(cut);
145
+ copy.prepare('UPDATE trajectory_meta SET cascade_id=?').run(id);
146
+ } finally { copy.close(); }
147
+ return { session_id: id };
148
+ }
149
+
150
+ /**
151
+ * Branch a session before the first of some taken-back executions it contains, leaving the original untouched.
152
+ *
153
+ * @param {object} profile The member's local software profile (`adapter`, `executable`).
154
+ * @param {string} session_id The session the member is bound to.
155
+ * @param {number[]} executions Execution IDs whose turns the branch must not contain.
156
+ * @param {object} options `always` branches even when none of them is in the session (a forked room needs its own copy).
157
+ * @returns {Promise<null|{session_id: string, fork?: {from: string, at: string}}>} null when nothing needs cutting;
158
+ * an empty session_id starts a new conversation; `fork` asks Claude's next turn to fork into session_id.
159
+ */
160
+ export async function branch_session(profile, session_id, executions, { always = false, env = process.env, home = homedir(), spawn_process = spawn } = {}) {
161
+ const markers = executions.map(execution_marker);
162
+ if (profile.adapter === 'claude') return claude_branch(session_id, markers, always, env, home);
163
+ // A Codex App chat is stored like a CLI one; the App loads the fork when the first message is sent to it.
164
+ if (['codex', 'codex_cli'].includes(profile.adapter)) return codex_branch(profile, session_id, markers, always, env, home, spawn_process);
165
+ if (profile.adapter === 'antigravity_cli') return agy_branch(session_id, markers, always, home);
166
+ throw new Error(`${profile.adapter} 的会话不能由本机截断`);
167
+ }