ai_co_discuss 0.3.11 → 0.3.13

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.
Files changed (46) hide show
  1. package/.agents/skills/discuss-participant/SKILL.md +1 -1
  2. package/backend/free_room.py +2410 -2274
  3. package/backend/schema.sql +27 -0
  4. package/backend/server.py +21 -11
  5. package/docs/architecture.md +2 -1
  6. package/docs/deployment.md +10 -0
  7. package/docs/en/deployment.md +10 -0
  8. package/docs/en/workflow.md +1 -1
  9. package/docs/runner.md +1 -1
  10. package/docs/workflow.md +2 -2
  11. package/package.json +3 -2
  12. package/runner/adapters/antigravity_app.mjs +91 -0
  13. package/runner/adapters/antigravity_cli.mjs +320 -0
  14. package/runner/adapters/antigravity_sidecar.mjs +50 -0
  15. package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
  16. package/runner/adapters/claude.mjs +313 -0
  17. package/runner/adapters/cli.mjs +126 -0
  18. package/runner/adapters/codex_app.mjs +70 -0
  19. package/runner/adapters/codex_cli.mjs +286 -0
  20. package/runner/adapters/common.mjs +196 -0
  21. package/runner/adapters/index.mjs +143 -0
  22. package/runner/adapters/profiles.mjs +116 -0
  23. package/runner/admin_account.mjs +1 -1
  24. package/runner/discussion_launch.mjs +69 -79
  25. package/runner/discussion_local.mjs +1 -1
  26. package/runner/discussion_runner.mjs +14 -13
  27. package/runner/discussion_service.mjs +3 -3
  28. package/runner/discussion_setup.mjs +1 -1
  29. package/runner/discussion_sidecar.mjs +5 -50
  30. package/runner/discussion_skills.mjs +1 -1
  31. package/runner/execution_progress.mjs +99 -0
  32. package/runner/expert_prompts.mjs +19 -29
  33. package/runner/files.mjs +10 -0
  34. package/runner/free_runner.mjs +136 -30
  35. package/runner/remote_launch.mjs +10 -5
  36. package/runner/scheduler.mjs +124 -32
  37. package/runner/session_driver.mjs +5 -3
  38. package/web/app.js +116 -23
  39. package/web/backup.js +6 -6
  40. package/web/index.html +2 -0
  41. package/web/modern.css +1 -1
  42. package/web/modern.js +1699 -1353
  43. package/runner/discussion_adapters.mjs +0 -959
  44. package/runner/discussion_models.mjs +0 -271
  45. package/runner/session_cut.mjs +0 -167
  46. /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
@@ -1,50 +1,5 @@
1
- /** Run ONLY inside an Antigravity-managed Sidecar configured for one IPC directory. */
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
5
- import { atomic_json } from './discussion_adapters.mjs';
6
-
7
- const directory = resolve(process.env.CODISCUSS_IPC_DIRECTORY || process.cwd());
8
- const agentapi = process.env.ANTIGRAVITY_AGENTAPI_EXE;
9
- if (!agentapi) throw new Error('此程序必须由 Antigravity App Sidecar 启动');
10
-
11
- /** Consume each request once; a crash after claim leaves an explicit uncertain result. */
12
- async function process_request(name) {
13
- const request = JSON.parse(readFileSync(join(directory, name), 'utf8'));
14
- if (!/^[a-zA-Z0-9_-]+$/.test(request.id) || name !== `${request.id}.request.json`) return;
15
- const claim = join(directory, `${request.id}.claimed`);
16
- const receipt = join(directory, `${request.id}.result.json`);
17
- if (existsSync(claim) || existsSync(receipt)) return;
18
- writeFileSync(claim, String(process.pid), { flag: 'wx', mode: 0o600 });
19
- try {
20
- const access = JSON.parse(readFileSync(request.access_path, 'utf8'));
21
- if (request.mode !== 'free') throw new Error('Sidecar 只支持自由协作执行请求,旧讨论事件已停用');
22
- const response = await fetch(`${access.site}/api/rooms/${access.room}/executions/${request.event_id}`, {
23
- method: 'GET', redirect: 'error', signal: AbortSignal.timeout(10000),
24
- headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': access.token },
25
- });
26
- const event = await response.json();
27
- if (!response.ok || event.status !== 'dispatched') throw new Error('请求已撤销或状态不明,未调用软件');
28
- if (event.lease_until <= Math.floor(Date.now() / 1000)) throw new Error('执行租约已到期,未调用软件');
29
- const args = request.session_id ? ['send-message', request.session_id, request.prompt] : ['new-conversation', request.prompt];
30
- const raw = execFileSync(agentapi, ['agentapi', ...args], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
31
- const result = JSON.parse(raw);
32
- const session_id = request.session_id || result.response?.newConversation?.conversationId;
33
- if (!session_id) throw new Error('未取得真实 conversation ID;创建结果待核对');
34
- atomic_json(request.access_path, { ...access, session_id });
35
- atomic_json(receipt, { id: request.id, session_id, status: 'accepted' });
36
- } catch (error) {
37
- atomic_json(receipt, { id: request.id, status: 'uncertain', error: String(error.message).slice(0, 500) });
38
- }
39
- }
40
-
41
- /** Scan only our own private IPC directory; this performs no model polling. */
42
- async function scan_requests() {
43
- for (const name of readdirSync(directory).filter(name => name.endsWith('.request.json'))) {
44
- try { await process_request(name); } catch (error) { process.stderr.write(`${error.message}\n`); }
45
- }
46
- }
47
-
48
- atomic_json(join(directory, 'ready.json'), { pid: process.pid, ready_at: Date.now(), project_id: process.env.ANTIGRAVITY_PROJECT_ID || '' });
49
- scan_requests();
50
- setInterval(scan_requests, 1000);
1
+ /**
2
+ * Entry the Antigravity App starts for an installed Sidecar. Installed Sidecars record this path, so it stays here;
3
+ * the Sidecar itself is part of the Antigravity App adapter.
4
+ */
5
+ import './adapters/antigravity_sidecar.mjs';
@@ -4,7 +4,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rm
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- import { atomic_json } from './discussion_adapters.mjs';
7
+ import { atomic_json } from './files.mjs';
8
8
 
9
9
  const SOURCE = fileURLToPath(new URL('../.agents/skills/', import.meta.url));
10
10
  export const SKILLS = ['discuss-host', 'discuss-participant', 'discuss-report'];
@@ -0,0 +1,99 @@
1
+ /**
2
+ * What each execution's software showed while it worked, kept until reported to the website: phase segments and
3
+ * progress notes. Fed by whichever adapter reached the software (see adapters/), read by the room runner.
4
+ */
5
+
6
+ /** Segments one execution may record, how many one report carries, and a tool name's length; the website enforces the same. */
7
+ export const MAX_EXECUTION_SEGMENTS = 400;
8
+ export const MAX_SEGMENTS_PER_REPORT = 40;
9
+ export const MAX_SEGMENT_TOOL = 60;
10
+ /** Progress notes kept per execution, each cut to this length, and how many one report carries; the website enforces the same. */
11
+ export const MAX_EXECUTION_NOTES = 50;
12
+ export const MAX_NOTE_LENGTH = 500;
13
+ export const MAX_NOTES_PER_REPORT = 10;
14
+
15
+ /**
16
+ * What each execution's software showed while it worked, waiting to be reported: the phases it went through, as
17
+ * millisecond offsets from its delivery on this machine's clock, and its progress notes. Consecutive events of the same
18
+ * phase and tool extend one segment. The open segment is reported as far as it has run and again once it runs further;
19
+ * numbering makes a resent report harmless.
20
+ */
21
+ export class SegmentLog {
22
+ constructor(now = Date.now) {
23
+ this.now = now;
24
+ this.executions = new Map();
25
+ }
26
+ /** Begin timing one execution at its delivery; a repeated start keeps the first origin. */
27
+ start(id) {
28
+ if (!this.executions.has(id)) this.executions.set(id, { origin: this.now(), last: 0, open: null, closed: [], next_seq: 0,
29
+ notes: [], next_note: 0, open_sent: null, reported: 0 });
30
+ }
31
+ /** Queue one progress note; notes past the cap are dropped. Cut by UTF-16 units like the website's check, never mid-pair. */
32
+ note(id, text) {
33
+ const log = this.executions.get(id);
34
+ if (!log || log.next_note >= MAX_EXECUTION_NOTES) return;
35
+ log.next_note += 1;
36
+ log.notes.push({ seq: log.next_note, text: text.slice(0, MAX_NOTE_LENGTH).replace(/[\uD800-\uDBFF]$/, '') });
37
+ }
38
+ /** The software produced output now; a phase, when the output names one, starts a new segment unless it continues the open one. */
39
+ mark(id, phase = '', tool = '') {
40
+ const log = this.executions.get(id);
41
+ if (!log) return;
42
+ log.last = this.now() - log.origin;
43
+ if (log.open) log.open.end_ms = log.last;
44
+ if (!phase) return;
45
+ tool = tool.slice(0, MAX_SEGMENT_TOOL).replace(/[\uD800-\uDBFF]$/, '');
46
+ if (log.open?.phase === phase && log.open.tool === tool) return;
47
+ if (log.open) log.closed.push(log.open);
48
+ log.open = null;
49
+ if (log.next_seq >= MAX_EXECUTION_SEGMENTS) return;
50
+ log.next_seq += 1;
51
+ log.open = { seq: log.next_seq, phase, tool, start_ms: log.last, end_ms: log.last };
52
+ }
53
+ /** Whether something new waits to be reported (an open segment only once it ran further) and the last report is `interval_ms` old. */
54
+ due(id, interval_ms) {
55
+ const log = this.executions.get(id);
56
+ const open_moved = log?.open && !(log.open_sent?.seq === log.open.seq && log.open_sent.end_ms === log.open.end_ms);
57
+ return Boolean(log && (log.closed.length || log.notes.length || open_moved) && this.now() - log.reported >= interval_ms);
58
+ }
59
+ /** The next report: closed segments first, then the open one as it stands, and the queued notes. `final` closes the open segment. */
60
+ batch(id, final = false) {
61
+ const log = this.executions.get(id);
62
+ if (!log) return { segments: [], notes: [] };
63
+ if (final && log.open) { log.closed.push(log.open); log.open = null; }
64
+ const segments = log.closed.slice(0, MAX_SEGMENTS_PER_REPORT);
65
+ if (log.open && segments.length < MAX_SEGMENTS_PER_REPORT) segments.push({ ...log.open });
66
+ return { segments, notes: log.notes.slice(0, MAX_NOTES_PER_REPORT) };
67
+ }
68
+ /** Forget what a successful report carried in full; a segment that ran on since it was sent stays. */
69
+ sent(id, batch) {
70
+ const log = this.executions.get(id);
71
+ if (!log) return;
72
+ const ends = new Map(batch.segments.map(segment => [segment.seq, segment.end_ms]));
73
+ log.closed = log.closed.filter(segment => !(ends.has(segment.seq) && segment.end_ms <= ends.get(segment.seq)));
74
+ const notes = new Set(batch.notes.map(note => note.seq));
75
+ log.notes = log.notes.filter(note => !notes.has(note.seq));
76
+ const open = log.open && batch.segments.find(segment => segment.seq === log.open.seq);
77
+ if (open) log.open_sent = { seq: open.seq, end_ms: open.end_ms };
78
+ log.reported = this.now();
79
+ }
80
+ /**
81
+ * Send the execution's segments and notes with `send({segments, notes})`. `final` closes the open segment, sends
82
+ * everything left and forgets the execution. A refused report drops them and any other failure keeps them for the
83
+ * next try; it never throws, so a progress report cannot fail the execution it describes.
84
+ */
85
+ async report(id, final, send) {
86
+ do {
87
+ const batch = this.batch(id, final);
88
+ if (!batch.segments.length && !batch.notes.length) break;
89
+ try { await send(batch); }
90
+ catch (error) {
91
+ if (/HTTP 4\d\d/.test(error.message)) this.drop(id);
92
+ return;
93
+ }
94
+ this.sent(id, batch);
95
+ } while (final && (this.executions.get(id)?.closed.length || this.executions.get(id)?.notes.length));
96
+ if (final) this.drop(id);
97
+ }
98
+ drop(id) { this.executions.delete(id); }
99
+ }
@@ -3,10 +3,12 @@
3
3
  * software or one member needs. Presets exist only where a software needs one; a member's own
4
4
  * system_prompt, even an empty one, replaces its preset. They add guidance and never widen permissions.
5
5
  */
6
+ // Plain data with no imports of its own, so this module stays loadable by the web page.
7
+ import { adapter_profile } from './adapters/profiles.mjs';
6
8
 
7
9
  export const MAX_EXPERT_PROMPT = 4000;
8
10
 
9
- // A software's must-know rules are fixed by the runner (agy below, API in its system prompt), so no preset is needed now.
11
+ // A software's must-know rules are fixed by the runner (its adapter profile's task_rules, API in its system prompt), so no preset is needed now.
10
12
  export const EXPERT_PROMPT_PRESETS = {};
11
13
 
12
14
  /**
@@ -51,35 +53,28 @@ export const PROJECT_ACCESS = ['read', 'write', 'auto', 'full'];
51
53
 
52
54
  const ACCESS_LABELS = { read: '只读', write: '可写', auto: '自动', full: '全放开' };
53
55
 
54
- /** What each level does in each software, measured headless (Claude Code 2.1.312, Codex CLI 0.163, agy 1.4.10). */
55
- const ACCESS_HINTS = {
56
- claude: { read: '只放行网站命令与草稿,不改项目文件、不跑其他命令', write: '可改项目文件,Bash / PowerShell 命令全部放行(无沙箱)',
57
- auto: 'Claude auto 模式:由分类器判断,实测项目外写入和联网都会放行', full: 'bypassPermissions:跳过全部权限检查' },
58
- codex_cli: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
59
- auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
60
- antigravity_cli: { read: '只放行网站命令;碰到未放行的命令会直接结束本轮', write: '可写项目,命令全部放行(在 agy 沙箱内)',
61
- full: '跳过全部权限确认(--dangerously-skip-permissions),并放开沙箱外命令与 MCP 工具' },
62
- };
63
-
64
- /** agy offers 自动 only in its interactive window; headless `--mode auto` is "not available for this account" (1.4.10–1.4.11). */
56
+ /** The levels a software can enforce headless, from its adapter profile; an App or API member is offered all of them. */
65
57
  export function access_levels(adapter) {
66
- return adapter === 'antigravity_cli' ? PROJECT_ACCESS.filter(level => level !== 'auto') : PROJECT_ACCESS;
58
+ return adapter_profile(adapter)?.access_levels || PROJECT_ACCESS;
67
59
  }
68
60
 
69
- // Why a level a software lacks is shown but disabled, so every CLI card lines up the same four levels.
70
- const UNSUPPORTED_HINTS = { antigravity_cli: { auto: 'agy 只在交互窗口提供自动模式,后台运行(-p)不支持' } };
71
-
72
- /** All four levels labelled for the page, with what each does in this software on hover; ones it lacks are disabled. */
61
+ /**
62
+ * All four levels labelled for the page, with what each does in this software on hover; ones it lacks are shown but
63
+ * disabled, so every CLI card lines up the same four levels.
64
+ */
73
65
  export function access_options(adapter) {
74
- const levels = access_levels(adapter);
66
+ const levels = access_levels(adapter), profile = adapter_profile(adapter);
75
67
  return PROJECT_ACCESS.map(value => levels.includes(value)
76
- ? { value, label: ACCESS_LABELS[value], hint: ACCESS_HINTS[adapter]?.[value] || '' }
77
- : { value, label: ACCESS_LABELS[value], hint: UNSUPPORTED_HINTS[adapter]?.[value] || '该软件不支持此档位', disabled: true });
68
+ ? { value, label: ACCESS_LABELS[value], hint: profile?.access_hints?.[value] || '' }
69
+ : { value, label: ACCESS_LABELS[value], hint: profile?.unsupported_hints?.[value] || '该软件不支持此档位', disabled: true });
78
70
  }
79
71
 
80
72
  /** The page's name for a level; `own` reads as decided by the member's own session. */
81
73
  export function access_label(level) { return level === 'own' ? '自行决定' : ACCESS_LABELS[level] || '只读'; }
82
74
 
75
+ /** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
76
+ export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'review', 'execution', 'health', 'schema'];
77
+
83
78
  /**
84
79
  * Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project.
85
80
  * A taken-over manual member (`own`) keeps its own session's permissions, so the room's limits do not apply.
@@ -98,14 +93,9 @@ export function project_rule(profile, purpose) {
98
93
  return profile.project_access === 'write' ? `用户允许${purpose}修改 cwd 中的项目文件并运行所需命令。` : 'cwd 中的项目文件只读,不修改。';
99
94
  }
100
95
 
101
- /** 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. */
102
- export function agy_command_rule(adapter, cli, config = {}) {
103
- return adapter === 'antigravity_cli' && !runs_commands(config) ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;列目录、搜索或读取文件用 list_dir、find_by_name、grep_search、view_file。` : '';
104
- }
105
-
106
- /** agy streams no thinking text, so without a word at each turning point the page cannot show how far it got; fixed, so no member prompt drops it. */
107
- export function agy_progress_rule(adapter) {
108
- return adapter === 'antigravity_cli' ? '多步任务中,在关键转折处用一句话写出进展再继续:agy 不输出思考过程,网页只能靠这些话显示你做到哪一步。' : '';
96
+ /** The fixed rules one software needs in every task, from its adapter profile; no member prompt can drop them. */
97
+ export function software_rules(config, cli) {
98
+ return adapter_profile(config.adapter)?.task_rules?.(cli, { runs_commands: runs_commands(config) }) || '';
109
99
  }
110
100
 
111
101
  /** Windows PowerShell mis-decodes UTF-8 files; the runner adds this where the member's commands can run rg. */
@@ -123,7 +113,7 @@ export const READ_RULE = '读含中文的文件用 rg --encoding utf-8 . 文件
123
113
  export function free_task_text({ name, discussion, discussion_request, profile, cli, room, messages, read_after, execution_id, task_path, access_file = '', read_rule = '' }) {
124
114
  const scope = discussion ? `讨论进行中,用户直接向你提问(${messages}):只回答这个提问,不改变讨论安排和进度。` : '';
125
115
  const commands = runs_commands(profile) ? '' : `命令只用 subcommands 列出的 ${cli} 子命令。`;
126
- const instructions = `${agy_command_rule(profile.adapter, cli, profile)}${agy_progress_rule(profile.adapter)}${scope}${project_rule(profile, '按请求')}${commands}草稿写在 draft_directory。`;
116
+ const instructions = `${software_rules(profile, cli)}${scope}${project_rule(profile, '按请求')}${commands}草稿写在 draft_directory。`;
127
117
  if (discussion_request) {
128
118
  const role = discussion_request.purpose === 'discuss_host' ? '主持任务' : '发言请求';
129
119
  const prompt = `你(${name})收到了讨论中的${role}。先读任务文件 ${task_path},按其中列出的技能处理本次请求并保存结果。${read_rule}`;
@@ -0,0 +1,10 @@
1
+ /** Small private files the runner keeps on this machine. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { renameSync, writeFileSync } from 'node:fs';
4
+
5
+ /** Persist small private state atomically without logging its contents. */
6
+ export function atomic_json(path, value) {
7
+ const temporary = `${path}.${randomUUID()}.tmp`;
8
+ writeFileSync(temporary, JSON.stringify(value, null, 2), { mode: 0o600 });
9
+ renameSync(temporary, path);
10
+ }
@@ -3,12 +3,13 @@ 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 { atomic_json, claude_inbox_ready, deliver_task, remove_agy_projects, 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';
6
+ import { adapter_of, BRANCHABLE_ADAPTERS, branch_session, capabilities_of, cleanup_room, deliver_task, delivery_receipt, session_exists, stored_delivery } from './adapters/index.mjs';
7
+ import { api, read_rule } from './discussion_runner.mjs';
8
+ import { SegmentLog } from './execution_progress.mjs';
9
+ import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
10
+ import { atomic_json } from './files.mjs';
9
11
  import { SessionDriver } from './session_driver.mjs';
10
- import { BRANCHABLE_ADAPTERS, branch_session, session_exists } from './session_cut.mjs';
11
- import { REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message } from './scheduler.mjs';
12
+ import { DEFAULT_SCHEDULER_MODE, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version } from './scheduler.mjs';
12
13
 
13
14
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
14
15
  const RENEW_MS = 30_000;
@@ -28,7 +29,7 @@ const GIT_TIMEOUT_MS = 15_000;
28
29
  /** Fingerprint configurations whose adapter can enforce project read-only access; no local paths leave this function. */
29
30
  export function review_capability_version(profile, member, config) {
30
31
  if (!profile || profile.project_access !== 'read' || member.access !== 'runner' || member.session_mode !== 'new'
31
- || profile.adapter !== 'codex_cli' || profile.adapter !== member.adapter
32
+ || !capabilities_of(profile.adapter).enforces_read_only || profile.adapter !== member.adapter
32
33
  || !profile.cwd || profile.allowed_tools?.length) return '';
33
34
  const project = cwd_key(profile.cwd), drafts = cwd_key(config.draft_directory || `${config.directory}-drafts`);
34
35
  if (drafts === project || drafts.startsWith(`${project}/`)) return '';
@@ -77,7 +78,7 @@ export function changed_between(before, after) {
77
78
  }
78
79
 
79
80
  /** Write a non-secret task and a member-only credential file for one execution. */
80
- export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], process_access = true } = {}) {
81
+ export function free_task_files(config, member, execution, binding, { session = null, suggestions = [], reviews = [], stopped = [], process_access = true } = {}) {
81
82
  const id = `free-${execution.id}`;
82
83
  const draft_directory = config.draft_directory || `${config.directory}-drafts`;
83
84
  mkdirSync(draft_directory, { recursive: true });
@@ -86,9 +87,10 @@ export function free_task_files(config, member, execution, binding, { session =
86
87
  const profile = config.participants[member.name];
87
88
  atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
88
89
  session_id: binding.session_id || '', execution_id: execution.id });
89
- const cli = process.platform === 'win32' && profile.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
90
- const inbox = claude_inbox_ready(profile, binding);
91
- const bound_access = process_access && !inbox && ['claude', 'codex_cli', 'antigravity_cli'].includes(profile.adapter);
90
+ const adapter = adapter_of(profile.adapter);
91
+ const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
92
+ const inbox = Boolean(adapter?.into_window?.(profile, binding));
93
+ const bound_access = process_access && !inbox && Boolean(adapter?.capabilities.access_env);
92
94
  const messages = execution.requests.map(item => `#${item.message_id}`).join('、');
93
95
  const requests = execution.requests || [];
94
96
  const discussion_request = requests.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose)) || null;
@@ -111,6 +113,7 @@ export function free_task_files(config, member, execution, binding, { session =
111
113
  goal: session.goal, named_followup: session.named_followup } } : {}),
112
114
  ...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
113
115
  ...(reviews.length ? { review_handoffs: reviews } : {}),
116
+ ...(stopped.length ? { stopped_executions: stopped } : {}),
114
117
  role: member.role || 'member',
115
118
  read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
116
119
  skills: skills.map(file => join(config.project, '.agents/skills', file)),
@@ -120,15 +123,13 @@ export function free_task_files(config, member, execution, binding, { session =
120
123
  skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt: with_expert_prompt(prompt, profile), inbox };
121
124
  }
122
125
 
123
- const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
124
- antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
125
126
 
126
127
  /** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
127
128
  function member_description(name, profile, work) {
128
129
  const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
129
- const access = profile.adapter === 'api' ? '' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
130
+ const access = !capabilities_of(profile.adapter).project ? ',只能读写房间消息' : profile.project_access === 'own' ? ',权限沿用其会话设置' : !profile.cwd ? ',未指定项目目录'
130
131
  : profile.project_access === 'read' ? `,项目 ${project} 只读` : `,可修改项目 ${project} 的文件并运行命令`;
131
- return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
132
+ return `${name}(${adapter_of(profile.adapter)?.label || profile.adapter}${access}${work})`;
132
133
  }
133
134
 
134
135
  /** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
@@ -165,6 +166,8 @@ export class FreeRoomRunner {
165
166
  this.routing = new Set();
166
167
  this.children = new Set();
167
168
  this.members = new Map();
169
+ // An empty set proves every tracked handle for this execution closed; a missing set proves nothing after restart.
170
+ this.execution_processes = new Map();
168
171
  // Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
169
172
  this.exited = new Map();
170
173
  this.snapshots = new Map();
@@ -210,7 +213,7 @@ export class FreeRoomRunner {
210
213
  */
211
214
  same_context(name, previous, current) {
212
215
  const [before, after] = [JSON.parse(previous), JSON.parse(current)];
213
- for (const context of [before, after]) if (CODEX_ADAPTERS.includes(context[0])) context[0] = 'codex';
216
+ for (const context of [before, after]) context[0] = adapter_of(context[0])?.session_family || context[0];
214
217
  const bound = this.store.binding(name).session_id;
215
218
  if (before[5] !== after[5] && [before[5], after[5]].includes('') && [before[5], after[5]].includes(bound)) after[5] = before[5];
216
219
  return JSON.stringify(before) === JSON.stringify(after);
@@ -225,10 +228,11 @@ export class FreeRoomRunner {
225
228
  room = await api(this.config, '');
226
229
  }
227
230
  catch (error) {
228
- if (error.message === 'HTTP 404: 房间不存在') remove_agy_projects(this.config.directory);
231
+ if (error.message === 'HTTP 404: 房间不存在') cleanup_room(this.config.directory);
229
232
  throw error;
230
233
  }
231
234
  if (!inbox.archived) await this.sync_review_capabilities(inbox);
235
+ if (!inbox.archived) await this.sync_member_capabilities(inbox);
232
236
  if (!inbox.archived && room.mode === 'free' && room.session?.status === 'active'
233
237
  && await this.session_driver.tick(room, inbox)) {
234
238
  // A scheduled plan completes the pending host request and creates a speaker request.
@@ -249,14 +253,17 @@ export class FreeRoomRunner {
249
253
  if (LIVE.includes(row.status) && !present.has(row.member)) { this.stop_member(row.member); this.save(row.member, { status: 'idle' }); }
250
254
  }
251
255
  await this.report_finished(active);
256
+ const pending_stops = await this.report_stops(active);
252
257
  // Start in the room's speaking order: execution ids follow it, and the website lays out working cards by them.
253
258
  const rank = (room.speaking_order?.items || []).map(item => item.name);
254
259
  const place = name => rank.includes(name) ? rank.indexOf(name) : rank.length;
255
260
  for (const member of [...inbox.members].sort((left, right) => place(left.name) - place(right.name))) {
256
261
  const row = this.row(member.name);
257
262
  if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active);
263
+ // A member voided in a window or App the runner could not stop waits until that turn ends (`held_until`).
258
264
  else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
259
- && !member.pending.some(request => this.routing.has(request.id))) await this.start(member, row);
265
+ && !pending_stops.has(member.name)
266
+ && !member.pending.some(request => this.routing.has(request.id)) && !(member.held_until * 1000 > Date.now())) await this.start(member, row);
260
267
  }
261
268
  }
262
269
  /** Publish only changed local facts, using member revisions supplied by the owner's inbox. */
@@ -274,6 +281,18 @@ export class FreeRoomRunner {
274
281
  } catch (error) { this.log(`只读评审能力未同步:${member.name}:${error.message}`); }
275
282
  }
276
283
  }
284
+ /** Tell the site what a rewind does to each member's session, as its adapter says; only a change is sent. */
285
+ async sync_member_capabilities(inbox) {
286
+ for (const member of inbox.members) {
287
+ const profile = this.config.participants[member.name];
288
+ // An older site lists no effect and takes no report.
289
+ if (member.rewind === undefined || !profile) continue;
290
+ const rewind = capabilities_of(profile.adapter).rewind;
291
+ if (member.rewind === rewind) continue;
292
+ try { await api(this.config, `/members/${member.id}/capabilities`, { rewind }); member.rewind = rewind; }
293
+ catch (error) { this.log(`成员会话能力未同步:${member.name}:${error.message}`); }
294
+ }
295
+ }
277
296
  /** Hold only pending review handoffs while their preauthorized next step is judged. */
278
297
  schedule_reviews(inbox) {
279
298
  const host = inbox.members.find(member => member.role === 'host');
@@ -432,7 +451,8 @@ export class FreeRoomRunner {
432
451
  let record;
433
452
  if (decision.detail) {
434
453
  if (result.status === 'failed') label += '(未生效,网站写入失败)';
435
- record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host');
454
+ // The @全体成员 question has one wording in every mode.
455
+ record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host', { prompt: ROUTE_PROMPT_VERSION });
436
456
  }
437
457
  this.save_suggestion(request, { action: decision.action, actual_action,
438
458
  applied: result.applied && decision.action !== 'host', model_judgment: Boolean(decision.detail),
@@ -462,7 +482,10 @@ export class FreeRoomRunner {
462
482
  const work = member_work(requests, member.name, message_id);
463
483
  const profile = this.config.participants[member.name];
464
484
  const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
465
- return { id: member.id, name: member.name, role: member.role, description: profile ? member_description(member.name, profile, `${work}${described}`)
485
+ // The model and the last finished message are only read by the guided wording.
486
+ const finished = (room.free?.requests || []).filter(item => item.member === member.name && item.status === 'completed' && item.message_id !== message_id).at(-1);
487
+ return { id: member.id, name: member.name, role: member.role, model: profile?.model || '', finished: finished ? `#${finished.message_id}` : '',
488
+ description: profile ? member_description(member.name, profile, `${work}${described}`)
466
489
  : `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work}${described})` };
467
490
  });
468
491
  }
@@ -523,7 +546,7 @@ export class FreeRoomRunner {
523
546
  return await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
524
547
  applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
525
548
  context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
526
- prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION, ...context } });
549
+ mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: route_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION, ...context } });
527
550
  } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
528
551
  }
529
552
  /** Reserve with a persisted client id before invoking any software. */
@@ -536,7 +559,7 @@ export class FreeRoomRunner {
536
559
  async reserve(member, record) {
537
560
  let execution;
538
561
  const profile = this.config.participants[member.name];
539
- const cwd = profile?.adapter === 'api' ? '' : cwd_key(profile?.cwd || this.config.cwd);
562
+ const cwd = profile && !capabilities_of(profile.adapter).project ? '' : cwd_key(profile?.cwd || this.config.cwd);
540
563
  try { execution = await api(this.config, '/executions', { member_id: member.id, client_id: record.client_id, cwd }); }
541
564
  catch (error) {
542
565
  if (/HTTP (404|409)/.test(error.message)) return this.save(member.name, { status: 'idle', error: '' });
@@ -556,7 +579,7 @@ export class FreeRoomRunner {
556
579
  }
557
580
  if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
558
581
  if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
559
- const directory = adapter.adapter === 'api' ? undefined : adapter.cwd || this.config.cwd;
582
+ const directory = capabilities_of(adapter.adapter).project ? adapter.cwd || this.config.cwd : undefined;
560
583
  // A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
561
584
  const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
562
585
  if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
@@ -572,8 +595,11 @@ export class FreeRoomRunner {
572
595
  : session_request?.purpose === 'discuss_host' ? this.session_driver.take_suggestions(session_request.id)
573
596
  : execution.requests.map(request => this.saved_suggestion(request.id)).filter(Boolean);
574
597
  const review_handoffs = await this.review_handoffs(execution);
598
+ // Executions the user stopped that this session may still hold; a rewind already cut its own out, and a new session holds none.
599
+ const rewound = new Set(execution.rewound_executions || []);
600
+ const stopped = binding.session_id ? this.stopped_executions(member.name).filter(id => !rewound.has(id)) : [];
575
601
  const task = free_task_files(this.config, member, execution, binding, {
576
- session, suggestions: scheduler_suggestions, reviews: review_handoffs,
602
+ session, suggestions: scheduler_suggestions, reviews: review_handoffs, stopped,
577
603
  });
578
604
  invoked = true;
579
605
  this.segments.start(execution.id);
@@ -585,7 +611,7 @@ export class FreeRoomRunner {
585
611
  requests: execution.requests, read_after: execution.read_until, session, discussion_request: session_request,
586
612
  scheduler_suggestions, review_handoffs },
587
613
  on_binding: session => this.store.bind(member.name, session),
588
- on_process: child => this.track(member.name, child),
614
+ on_process: child => this.track(member.name, child, execution.id),
589
615
  on_output: (path, chunk) => { appendFileSync(path, chunk, { mode: 0o600 }); this.touch(execution.id); this.segments.mark(execution.id); },
590
616
  on_activity: (phase, tool) => { this.touch(execution.id, phase); this.segments.mark(execution.id, phase, tool); },
591
617
  on_note: text => this.note(execution.id, text),
@@ -596,7 +622,9 @@ export class FreeRoomRunner {
596
622
  on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
597
623
  });
598
624
  if (result.session_id) this.store.bind(member.name, result.session_id);
625
+ this.clear_stopped(member.name);
599
626
  this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
627
+ await this.report_delivery(execution.id, result.capabilities || capabilities_of(adapter.adapter).delivery, member.name);
600
628
  } catch (error) {
601
629
  if (review_request && error.delivery_status === 'not_sent') return this.reject_review(member, execution, error.message);
602
630
  if (adapter.project_access === 'own') return this.retry_or_fail(member.name, execution, `投递失败:${error.message}`);
@@ -660,7 +688,7 @@ export class FreeRoomRunner {
660
688
  async reconcile(member, row, active) {
661
689
  if (row.status === 'reserving') return this.reserve(member, row);
662
690
  if (row.status === 'sidecar_pending') {
663
- const receipt = sidecar_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
691
+ const receipt = delivery_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
664
692
  if (receipt?.status === 'accepted' && receipt.session_id) {
665
693
  this.store.bind(member.name, receipt.session_id);
666
694
  this.save(member.name, { status: 'running' });
@@ -705,6 +733,79 @@ export class FreeRoomRunner {
705
733
  this.save(member.name, { last_renew: Date.now() });
706
734
  }
707
735
  }
736
+ /**
737
+ * Tell the site what this delivery can do, as the adapter reported it: whether the runner can stop it and see its
738
+ * progress. Kept locally until the execution ends so a restarted runner still knows whether it could stop it.
739
+ * A lost report is retained for the final stop result; sites and runners must use the same capability protocol.
740
+ */
741
+ async report_delivery(execution_id, { stoppable, progress }, member) {
742
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)').run(`delivery:${execution_id}`, JSON.stringify({ stoppable, progress, member }));
743
+ await api(this.config, `/executions/${execution_id}`, { action: 'delivery', stoppable, progress })
744
+ .catch(error => this.log(`执行 #${execution_id} 的投递能力未能上报:${error.message}`));
745
+ }
746
+ /** Retry durable stop reports even after the member went idle, left, or started a later execution. */
747
+ async report_stops(active) {
748
+ const pending = new Set();
749
+ for (const { key, value } of this.store.db.prepare("SELECT key,value FROM metadata WHERE key LIKE 'delivery:%'").all()) {
750
+ const execution_id = Number(key.slice('delivery:'.length));
751
+ if (active.has(execution_id)) continue;
752
+ const delivery = stored_delivery(value);
753
+ const name = delivery.member || this.store.db.prepare('SELECT member FROM free_deliveries WHERE execution_id=?').get(execution_id)?.member;
754
+ if (!name) continue;
755
+ for (const child of this.execution_processes.get(execution_id) || []) this.request_stop(child);
756
+ try { await this.report_stop(name, execution_id); }
757
+ catch (error) { pending.add(name); this.log(`执行 #${execution_id} 的停止结果未能上报:${error.message}`); }
758
+ }
759
+ return pending;
760
+ }
761
+ /** Report observed termination, never infer it from a capability or a successfully sent kill signal. */
762
+ async report_stop(name, execution_id) {
763
+ const key = `delivery:${execution_id}`;
764
+ const value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
765
+ if (!value) return;
766
+ const delivery = stored_delivery(value), processes = this.execution_processes.get(execution_id);
767
+ const ended = processes ? processes.size === 0 : this.exited.has(execution_id);
768
+ const result = delivery.pending_result && delivery.pending_result !== 'unconfirmed' ? delivery.pending_result
769
+ : !delivery.stoppable ? 'unstoppable' : ended ? 'stopped' : 'unconfirmed';
770
+ if (delivery.stop_result === result) return;
771
+ // Keep the observed outcome before sending: a restart after a lost response must retry the same fact.
772
+ this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
773
+ .run(JSON.stringify({ ...delivery, member: name, pending_result: result }), key);
774
+ try { await api(this.config, `/executions/${execution_id}`, { action: 'stopped', result }); }
775
+ catch (error) {
776
+ if (!/HTTP 409:/.test(error.message)) throw error;
777
+ const latest = await api(this.config, `/executions/${execution_id}`);
778
+ // A conflict is not necessarily normal completion: keep cancelled reports until an idempotent receipt arrives.
779
+ if (!['completed', 'failed'].includes(latest.status)) throw error;
780
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
781
+ this.execution_processes.delete(execution_id);
782
+ return;
783
+ }
784
+ this.store.db.prepare('INSERT OR REPLACE INTO metadata VALUES (?,?)')
785
+ .run(`stopped:${name}`, JSON.stringify([...new Set([...this.stopped_executions(name), execution_id])]));
786
+ if (result === 'unconfirmed') {
787
+ this.store.db.prepare('UPDATE metadata SET value=? WHERE key=?')
788
+ .run(JSON.stringify({ ...delivery, member: name, pending_result: result, stop_result: result }), key);
789
+ // The site's bounded hold now controls the next delivery; retain handles to observe and retry termination.
790
+ for (const child of processes || []) this.members.get(name)?.delete(child);
791
+ }
792
+ else { this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key); this.execution_processes.delete(execution_id); }
793
+ }
794
+ /** Once a new task received the stop reminders, retire acknowledged unconfirmed reports for its old turns. */
795
+ clear_stopped(name) {
796
+ for (const id of this.stopped_executions(name)) {
797
+ const key = `delivery:${id}`, value = this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(key)?.value;
798
+ if (value && stored_delivery(value).stop_result === 'unconfirmed') {
799
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(key);
800
+ this.execution_processes.delete(id);
801
+ }
802
+ }
803
+ this.store.db.prepare('DELETE FROM metadata WHERE key=?').run(`stopped:${name}`);
804
+ }
805
+ /** Executions the user stopped since this member's last delivered task. */
806
+ stopped_executions(name) {
807
+ return JSON.parse(this.store.db.prepare('SELECT value FROM metadata WHERE key=?').get(`stopped:${name}`)?.value || '[]');
808
+ }
708
809
  /** Send the execution's recorded segments and progress notes; `final` sends the rest once it has ended. */
709
810
  report_segments(execution_id, final = false) {
710
811
  return this.segments.report(execution_id, final, ({ segments, notes }) =>
@@ -726,7 +827,7 @@ export class FreeRoomRunner {
726
827
  /** Report observed file changes once an execution has ended and its member's processes have exited. */
727
828
  async report_finished(active) {
728
829
  for (const [execution_id, snapshot] of this.snapshots) {
729
- if (active.has(execution_id) || this.members.get(snapshot.member)?.size) continue;
830
+ if (active.has(execution_id) || this.execution_processes.get(execution_id)?.size) continue;
730
831
  this.snapshots.delete(execution_id);
731
832
  const after = workspace_snapshot(snapshot.directory);
732
833
  if (!after) continue;
@@ -759,14 +860,19 @@ export class FreeRoomRunner {
759
860
  this.stop_member(name);
760
861
  }
761
862
  /** Count live processes per member so one session never has two writers. */
762
- track(name, child) {
863
+ track(name, child, execution_id = this.row(name)?.execution_id) {
763
864
  this.children.add(child);
764
865
  if (!this.members.has(name)) this.members.set(name, new Set());
765
866
  this.members.get(name).add(child);
766
- child.once('close', () => { this.children.delete(child); this.members.get(name).delete(child); });
867
+ if (!this.execution_processes.has(execution_id)) this.execution_processes.set(execution_id, new Set());
868
+ const processes = this.execution_processes.get(execution_id);
869
+ processes.add(child);
870
+ child.once('close', () => { processes.delete(child); this.children.delete(child); this.members.get(name).delete(child); });
767
871
  }
872
+ /** Request termination; only a later close event proves it happened. */
873
+ request_stop(child) { try { child.kill(); } catch { /* The stop report remains unconfirmed. */ } }
768
874
  /** Stop one member's processes without touching other members or desktop apps. */
769
- stop_member(name) { for (const child of this.members.get(name) || []) child.kill(); }
875
+ stop_member(name) { for (const child of this.members.get(name) || []) this.request_stop(child); }
770
876
  /** Stop every process this runner created. */
771
- stop_children() { for (const child of this.children) child.kill(); }
877
+ stop_children() { for (const child of this.children) this.request_stop(child); }
772
878
  }