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
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What each supported software is and can do, as plain data: the adapter layer's static facts. Imports nothing, so the
3
+ * runner, the launch settings and the web page (bundled by Vite) all read the same table; behaviour lives in each
4
+ * software's own module (see index.mjs). Product code reads these fields, never a software name (AGENTS.md red line 9).
5
+ *
6
+ * Fields of one profile:
7
+ * - `platform`: the AI platform it serves (`Codex`, `Claude`, `Gemini`); '' serves any platform (model API).
8
+ * - `label`: display name. `kind`: `cli` (a process the runner starts), `app` (a desktop App) or `api` (the runner's own call).
9
+ * - `location`: the config field locating the installed software (`executable`, `ipc_directory`), or '' when none.
10
+ * - `fields`: further config fields the user may set for it.
11
+ * - `efforts`: thinking levels it accepts, or null when chosen inside the App; `efforts_per_model`: the levels are read
12
+ * from the software for the chosen model. `models`: whether its CLI lists models.
13
+ * - `access_levels`: project access levels it can enforce per run, or null when the App or API decides; `access_hints`
14
+ * says what each level does in it (measured headless: Claude Code 2.1.312, Codex CLI 0.163, agy 1.4.10) and
15
+ * `unsupported_hints` why a level it lacks is disabled.
16
+ * - `per_tool_allow`: a refused operation can be allowed one rule at a time.
17
+ * - `client`: the software a manual member says it runs in when it registers (`codex`, `claude`, `antigravity`).
18
+ * - `adopt`: an existing conversation can be taken over: `session`, or `own_cwd` when it must resume in the directory it
19
+ * was made in; '' when it cannot. `host_existing`: it may also host from, or be bound to, an existing conversation.
20
+ * `own_project_when_bound`: bound to an existing conversation, it keeps that conversation's project and permissions.
21
+ * `restore_manual`: a restored room keeps it manual, never taken over (a background resume would fork the session
22
+ * away from its original window).
23
+ * `resume_command(session_id)`: a command the owner can copy to reopen a restored manual conversation.
24
+ * - `defaults_by_query`: its default model is read by running it (seconds), so a quick read leaves it for later.
25
+ * - `capabilities`: `delivery` `{stoppable, progress}` of a usual delivery (each delivery reports its own); `rewind`: `cut`
26
+ * (the runner cuts the session), `fresh` (context rebuilt each time) or `kept` (cannot be cut here); `project` (works in
27
+ * a project directory); `access_env` (its process takes the credential file by environment); `enforces_read_only`
28
+ * (its sandbox enforces a read-only project); `ripgrep` (bundles rg for reading on Windows).
29
+ * - `session_family`: profiles sharing one session store; switching among them keeps the conversation.
30
+ * - `windows_command`: how its shell names the codiscuss CLI on Windows.
31
+ * - `task_rules(cli, {runs_commands})`: fixed task text this software needs, added by the runner so no member prompt drops it.
32
+ */
33
+ const ALL_ACCESS = ['read', 'write', 'auto', 'full'];
34
+ const PROCESS = { stoppable: true, progress: true };
35
+ const IN_APP = { stoppable: false, progress: false };
36
+
37
+ export const ADAPTER_PROFILES = {
38
+ codex_cli: {
39
+ platform: 'Codex', label: 'Codex CLI', kind: 'cli', location: 'executable', fields: ['project_id', 'project_name'],
40
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], models: true, access_levels: ALL_ACCESS, per_tool_allow: false,
41
+ access_hints: { read: '只读沙箱', write: '项目可写的沙箱,命令在沙箱内运行、可联网',
42
+ auto: '沙箱外的操作由模型申请提权,Codex 自动审核代替人工批准', full: '无沙箱、无审批(--dangerously-bypass-approvals-and-sandbox)' },
43
+ client: 'codex', adopt: 'session', host_existing: true, session_family: 'codex',
44
+ capabilities: { delivery: PROCESS, rewind: 'cut', project: true, access_env: true, enforces_read_only: true, ripgrep: true },
45
+ },
46
+ codex: {
47
+ platform: 'Codex', label: 'Codex App', kind: 'app', location: 'executable', fields: ['source_thread_id', 'project_id', 'project_name'],
48
+ efforts: null, models: false, access_levels: null, per_tool_allow: false,
49
+ client: 'codex', adopt: 'session', host_existing: true, session_family: 'codex',
50
+ capabilities: { delivery: IN_APP, rewind: 'cut', project: true },
51
+ },
52
+ claude: {
53
+ platform: 'Claude', label: 'Claude Code', kind: 'cli', location: 'executable', fields: ['allowed_tools'],
54
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'], models: true, access_levels: ALL_ACCESS, per_tool_allow: true,
55
+ access_hints: { read: '只放行网站命令与草稿,不改项目文件、不跑其他命令', write: '可改项目文件,Bash / PowerShell 命令全部放行(无沙箱)',
56
+ auto: 'Claude auto 模式:由分类器判断,实测项目外写入和联网都会放行', full: 'bypassPermissions:跳过全部权限检查' },
57
+ client: 'claude', adopt: 'own_cwd', host_existing: true, restore_manual: true,
58
+ resume_command: session_id => `claude --resume ${session_id}`,
59
+ // Claude Code runs commands through bash on Windows too, where the npm shim works without .cmd.
60
+ windows_command: 'codiscuss',
61
+ capabilities: { delivery: PROCESS, rewind: 'cut', project: true, access_env: true },
62
+ },
63
+ antigravity_cli: {
64
+ platform: 'Gemini', label: 'Antigravity CLI', kind: 'cli', location: 'executable', fields: ['project_id', 'project_name'],
65
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'], efforts_per_model: true, models: true, defaults_by_query: true,
66
+ // Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11), so 自动 is not offered.
67
+ access_levels: ['read', 'write', 'full'], per_tool_allow: true,
68
+ access_hints: { read: '只放行网站命令;碰到未放行的命令会直接结束本轮', write: '可写项目,命令全部放行(在 agy 沙箱内)',
69
+ full: '跳过全部权限确认(--dangerously-skip-permissions),并放开沙箱外命令与 MCP 工具' },
70
+ unsupported_hints: { auto: 'agy 只在交互窗口提供自动模式,后台运行(-p)不支持' },
71
+ // Only a conversation this tool created gets its discussion project; an existing one keeps its own project and permissions.
72
+ client: 'antigravity', adopt: 'session', host_existing: false, own_project_when_bound: true,
73
+ capabilities: { delivery: PROCESS, rewind: 'cut', project: true, access_env: true },
74
+ // A read-only agy project allows only codiscuss commands and headless agy ends the turn on a denied one; agy streams no
75
+ // thinking text, so without a word at each turning point the page cannot show how far it got.
76
+ task_rules: (cli, { runs_commands }) => (runs_commands ? ''
77
+ : `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;列目录、搜索或读取文件用 list_dir、find_by_name、grep_search、view_file。`)
78
+ + '多步任务中,在关键转折处用一句话写出进展再继续:agy 不输出思考过程,网页只能靠这些话显示你做到哪一步。',
79
+ },
80
+ antigravity: {
81
+ platform: 'Gemini', label: 'Antigravity App', kind: 'app', location: 'ipc_directory', fields: ['project_id', 'project_name'],
82
+ efforts: null, models: false, access_levels: null, per_tool_allow: false,
83
+ client: 'antigravity', adopt: 'session', host_existing: true,
84
+ capabilities: { delivery: IN_APP, rewind: 'kept', project: true },
85
+ },
86
+ api: {
87
+ platform: '', label: '模型 API', kind: 'api', location: '', fields: ['base_url', 'api_protocol', 'api_key_ref'],
88
+ efforts: null, models: false, access_levels: null, per_tool_allow: false, adopt: '', host_existing: false,
89
+ capabilities: { delivery: PROCESS, rewind: 'fresh', project: false },
90
+ },
91
+ };
92
+
93
+ /** Every configured `adapter` value this version supports. */
94
+ export const ADAPTER_IDS = Object.keys(ADAPTER_PROFILES);
95
+
96
+ /** The profile of one adapter with its defaults filled in, or null for a value this version does not support. */
97
+ export function adapter_profile(id) {
98
+ const profile = ADAPTER_PROFILES[id];
99
+ return profile ? { id, session_family: id, windows_command: 'codiscuss.cmd', ...profile } : null;
100
+ }
101
+
102
+ /** Whether two different adapters share one session store, so switching between them keeps the conversation. */
103
+ export function same_session_family(left, right) {
104
+ const [a, b] = [adapter_profile(left), adapter_profile(right)];
105
+ return Boolean(a && b && left !== right && a.session_family === b.session_family);
106
+ }
107
+
108
+ /** The adapters serving one platform, in table order; a model API serves every platform. */
109
+ export function platform_adapters(platform) {
110
+ return ADAPTER_IDS.filter(id => ADAPTER_PROFILES[id].platform === platform || ADAPTER_PROFILES[id].platform === '');
111
+ }
112
+
113
+ /** What an adapter can do; an unknown adapter can do nothing. */
114
+ export function adapter_capabilities(id) {
115
+ return ADAPTER_PROFILES[id]?.capabilities || { delivery: IN_APP, rewind: 'kept', project: false };
116
+ }
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
 
8
- import { atomic_json } from './discussion_adapters.mjs';
8
+ import { atomic_json } from './files.mjs';
9
9
  import { service_home } from './discussion_service.mjs';
10
10
 
11
11
  const DEFAULT_CONFIG = join('.wrangler', 'deploy', 'wrangler.jsonc');
@@ -9,17 +9,17 @@ import { setTimeout as sleep } from 'node:timers/promises';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import { parseArgs } from 'node:util';
11
11
 
12
- import { atomic_json, setup_sidecar } from './discussion_adapters.mjs';
13
- import { session_settings, software_defaults, software_effort, software_models } from './discussion_models.mjs';
12
+ import { atomic_json } from './files.mjs';
13
+ import { setup_sidecar } from './adapters/antigravity_app.mjs';
14
+ import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
15
+ import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
14
16
  import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
15
- import { SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
17
+ import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
16
18
  import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
17
- import { BRANCHABLE_ADAPTERS, branch_session } from './session_cut.mjs';
18
- import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
19
+ import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
19
20
  import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
20
21
  import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
21
22
 
22
- const CLIENTS = { Codex: ['codex_cli', 'codex', 'api'], Claude: ['claude', 'api'], Gemini: ['antigravity_cli', 'antigravity', 'api'], DeepSeek: ['api'], Qwen: ['api'], Grok: ['api'] };
23
23
  // A platform is a type; `Claude-评审` is another Claude instance with its own session, model and effort.
24
24
  const INSTANCE_PATTERN = /^(Codex|Claude|Gemini|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_一-鿿]{1,16})?$/;
25
25
 
@@ -33,10 +33,7 @@ export function next_instance(platform, taken) {
33
33
  }
34
34
 
35
35
  /** Supported local clients of an instance's platform. */
36
- function clients_of(name) { return CLIENTS[platform_of(name)]; }
37
- // The UI discovers each model's supported subset; omitted values keep the CLI's own default.
38
- const EFFORT_LEVELS = { codex_cli: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], claude: ['low', 'medium', 'high', 'xhigh', 'max'],
39
- antigravity_cli: ['low', 'medium', 'high', 'xhigh', 'max'] };
36
+ function clients_of(name) { return platform_adapters(platform_of(name)); }
40
37
  const PROFILE_FIELDS = ['adapter', 'executable', 'model', 'effort', 'source_thread_id', 'ipc_directory', 'allowed_tools', 'cwd', 'project_name', 'project_id', 'base_url', 'api_protocol', 'api_key_ref', 'system_prompt'];
41
38
  const MAX_MINUTES = 1440;
42
39
  const SESSION_ID_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
@@ -232,7 +229,7 @@ export async function cached_software_models(home, profile, { refresh = false, q
232
229
  const cached = entry => !refresh && entry && Date.now() - entry.at < MODEL_CACHE_MS ? entry.result : null;
233
230
  // Defaults are read on every request, so a changed software setting shows without waiting out the cache.
234
231
  const [result, effort, defaults] = await Promise.all([cached(cache[key]) || query(profile),
235
- profile.adapter === 'antigravity_cli' ? cached(cache[effort_key]) || query_effort(profile) : null, query_defaults(profile)]);
232
+ adapter_profile(profile.adapter)?.efforts_per_model ? cached(cache[effort_key]) || query_effort(profile) : null, query_defaults(profile)]);
236
233
  const updates = {};
237
234
  if (!cached(cache[key]) && !result.error && result.models.length) {
238
235
  updates[key] = { at: Date.now(), result };
@@ -273,20 +270,22 @@ export async function room_models(home, raw, { read_defaults = software_defaults
273
270
  let sessions = {};
274
271
  try { if (paths.length) sessions = bound_sessions(config, paths[0]); } catch { /* No readable runtime yet: fall back to borrowed sessions. */ }
275
272
  const participants = Object.fromEntries(await Promise.all(Object.entries(profiles).map(async ([name, profile]) => {
276
- const cli = ['codex_cli', 'claude', 'antigravity_cli', 'api'].includes(profile.adapter);
277
- // agy has no settings file and is run to name its default model, which takes seconds; a quick read leaves that to a later full read.
278
- const pending = input.quick === true && profile.adapter === 'antigravity_cli';
273
+ const facts = adapter_profile(profile.adapter);
274
+ // An App chooses its model itself; a CLI or API member's card sets it.
275
+ const card_model = facts?.kind !== 'app';
276
+ // A software read by running it to name its default model takes seconds; a quick read leaves that to a later full read.
277
+ const pending = input.quick === true && Boolean(facts?.defaults_by_query);
279
278
  const defaults = pending ? { model: '', effort: '' } : await read_defaults(profile);
280
- const actual = profile.adapter === 'api' ? { model: '', effort: '' } : read_session(profile.adapter, sessions[name] || profile.session_id);
281
- return [name, { ...(pending ? { defaults_pending: true } : {}), adapter: profile.adapter, model: cli ? profile.model || '' : '',
282
- effort: EFFORT_LEVELS[profile.adapter] ? profile.effort || '' : '', default_model: defaults.model, default_effort: defaults.effort,
279
+ const actual = read_session(profile.adapter, sessions[name] || profile.session_id);
280
+ return [name, { ...(pending ? { defaults_pending: true } : {}), adapter: profile.adapter, model: card_model ? profile.model || '' : '',
281
+ effort: facts?.efforts ? profile.effort || '' : '', default_model: defaults.model, default_effort: defaults.effort,
283
282
  ...(actual.model ? { actual_model: actual.model } : {}), ...(actual.effort ? { actual_effort: actual.effort } : {}),
284
- project_access: profile.adapter === 'api' ? 'read' : profile.project_access || config.project_access || 'read',
285
- // An agy member resuming its own conversation keeps that conversation's project, so this tool cannot set its access.
286
- own_project: profile.adapter === 'antigravity_cli' && Boolean(profile.session_id) }];
283
+ project_access: !facts?.capabilities.project ? 'read' : profile.project_access || config.project_access || 'read',
284
+ // A member bound to its own conversation may keep that conversation's project, so this tool cannot set its access.
285
+ own_project: Boolean(facts?.own_project_when_bound && profile.session_id) }];
287
286
  })));
288
287
  // Smart scheduling is a local choice; only which model decides is shown, never its key.
289
- const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model } : null;
288
+ const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
290
289
  return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler };
291
290
  }
292
291
 
@@ -390,10 +389,11 @@ function validate_profile(name, profile, binding = false) {
390
389
  if (key === 'model' && value && !/^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/.test(value)) throw new Error(`${name}.model 只能是模型名称,例如 gpt-6-astra、opus 或 gemini-4-argon-high`);
391
390
  if (key === 'system_prompt' && value.length > MAX_EXPERT_PROMPT) throw new Error(`${name} 的专属提示词最多 ${MAX_EXPERT_PROMPT} 字`);
392
391
  }
393
- if (profile.adapter === 'api') validate_api_profile(profile);
394
- if (profile.effort && profile.adapter !== 'api' && !EFFORT_LEVELS[profile.adapter]?.includes(profile.effort)) {
395
- throw new Error(EFFORT_LEVELS[profile.adapter] ? `${name}.effort 只能是 ${EFFORT_LEVELS[profile.adapter].join(' / ')}`
396
- : `${name} 的思考档位在原软件中选择`);
392
+ const facts = adapter_profile(profile.adapter);
393
+ if (facts?.kind === 'api') validate_api_profile(profile);
394
+ // The UI discovers each model's supported subset; omitted values keep the CLI's own default.
395
+ if (profile.effort && facts?.kind !== 'api' && !facts?.efforts?.includes(profile.effort)) {
396
+ throw new Error(facts?.efforts ? `${name}.effort 只能是 ${facts.efforts.join(' / ')}` : `${name} 的思考档位在原软件中选择`);
397
397
  }
398
398
  }
399
399
 
@@ -494,17 +494,18 @@ function participant_profile(name, defaults, input, current_host) {
494
494
  for (const field of ['cwd', 'model', 'effort', 'project_name', 'project_id', 'project_access']) if (profile[field] === '') delete profile[field];
495
495
  validate_profile(name, profile, true);
496
496
  if (!clients_of(name)?.includes(profile.adapter)) throw new Error(`请配置 ${name} 的 CLI / App 客户端`);
497
- if (profile.adapter === 'api') {
497
+ const facts = adapter_profile(profile.adapter);
498
+ if (facts?.kind === 'api') {
498
499
  if (input.entry === 'conversation' && current_host) throw new Error('API 主持人请从网页启动;当前软件会话主持须选择对应 CLI / App');
499
500
  return profile;
500
501
  }
501
- if (profile.adapter !== 'antigravity' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
502
+ if (facts?.location === 'executable' && (!existsSync(profile.executable || '') || !statSync(profile.executable).isFile()
502
503
  || (process.platform === 'win32' && !/\.exe$/i.test(profile.executable)))) throw new Error(`${name}:请指定实际原生可执行文件,Windows 使用 .exe`);
503
504
  const cwd = profile.cwd || input.cwd;
504
505
  if (cwd && (!existsSync(cwd) || !statSync(cwd).isDirectory())) throw new Error(`${name}:工作目录不存在或不是文件夹,请核对本次目录配置`);
505
506
  if (input.entry === 'conversation' && current_host) profile.session_id = input.current_session_id;
506
507
  // A new agy conversation always gets this tool's discussion project; only a manual agy expert the owner chose to host (input.adopt) keeps its own.
507
- if (profile.adapter === 'antigravity_cli' && profile.session_id && !input.adopt) throw new Error(`${name}:agy 不能绑定已有会话或从当前 agy 对话主持全自动讨论;请从网页或其他软件发起,agy 会由讨论服务新建会话`);
508
+ if (!facts?.host_existing && profile.session_id && !input.adopt) throw new Error(`${name}:${facts?.label || profile.adapter} 不能绑定已有会话或从当前对话主持全自动讨论;请从网页或其他软件发起,它会由讨论服务新建会话`);
508
509
  return profile;
509
510
  }
510
511
 
@@ -528,21 +529,15 @@ function same_directory(left, right) {
528
529
  export function borrowed_session(name, profile, room_cwd, env = process.env) {
529
530
  const id = profile.session_id;
530
531
  if (!SESSION_ID_PATTERN.test(id)) throw new Error(`${name}:会话 ID 格式不正确`);
531
- if (CODEX_ADAPTERS.includes(profile.adapter)) {
532
- const sessions = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
533
- if (!session_files(sessions).some(path => path.endsWith(`-${id}.jsonl`))) throw new Error(`${name}:本机找不到 Codex 会话 ${id},请核对会话 ID`);
534
- return profile;
535
- }
536
- if (['antigravity', 'antigravity_cli'].includes(profile.adapter)) {
537
- if (!existsSync(agy_conversation(profile.adapter, id))) throw new Error(`${name}:本机找不到 agy 会话 ${id},请核对会话 ID`);
538
- return profile;
532
+ const adapter = adapter_of(profile.adapter);
533
+ if (!adapter?.adopt || !adapter.session) {
534
+ const adoptable = ADAPTER_IDS.filter(item => ADAPTER_PROFILES[item].adopt).map(item => ADAPTER_PROFILES[item].label);
535
+ throw new Error(`${name}:只有 ${adoptable.join('、')} 可以接入已有会话`);
539
536
  }
540
- if (profile.adapter !== 'claude') throw new Error(`${name}:只有 Codex、Claude 与 agy 可以接入已有会话`);
541
- const projects = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
542
- const file = session_files(projects).find(path => path.endsWith(`${id}.jsonl`) && dirname(dirname(path)) === projects);
543
- if (!file) throw new Error(`${name}:本机找不到 Claude 会话 ${id},请核对会话 ID`);
544
- const cwd = readFileSync(file, 'utf8').split('\n').map(line => { try { return JSON.parse(line).cwd; } catch { return undefined; } }).find(Boolean);
545
- if (!cwd) throw new Error(`${name}:Claude 会话 ${id} 没有记录原工作目录,无法续接`);
537
+ if (!adapter.session.exists(id, { env })) throw new Error(`${name}:本机找不到 ${adapter.label} 会话 ${id},请核对会话 ID`);
538
+ if (adapter.adopt !== 'own_cwd') return profile;
539
+ const cwd = adapter.session.cwd(id, { env });
540
+ if (!cwd) throw new Error(`${name}:${adapter.label} 会话 ${id} 没有记录原工作目录,无法续接`);
546
541
  const wanted = profile.cwd || room_cwd;
547
542
  if (wanted && !same_directory(wanted, cwd)) throw new Error(`${name}:这个 Claude 会话属于 ${cwd},只能在原目录续接;请让项目目录与它一致,或留空会话 ID 新建会话`);
548
543
  return { ...profile, cwd: wanted || cwd };
@@ -628,8 +623,6 @@ export async function room_member(home, raw, locked = false) {
628
623
  } finally { release(); }
629
624
  }
630
625
 
631
- // CLIs whose project file access this tool sets per run; App members follow the App's own settings, API members only read.
632
- const ACCESS_ADAPTERS = ['codex_cli', 'claude', 'antigravity_cli'];
633
626
 
634
627
  /**
635
628
  * Switch one woken member's project files between read-only and writable after the room is created. Every run builds its
@@ -645,9 +638,11 @@ export async function room_member_access(home, raw) {
645
638
  const profile = config.participants?.[name];
646
639
  if (!profile) throw new Error(`${name} 不由本机唤醒,项目权限由它自己的会话决定`);
647
640
  if (profile.project_access === 'own') throw new Error(`${name} 是手动接入的成员,权限沿用它自己会话的软件设置`);
648
- if (!access_levels(profile.adapter).includes(project_access)) throw new Error('agy 无人值守不支持自动档,请选只读、可写或全放开');
649
- if (!ACCESS_ADAPTERS.includes(profile.adapter)) throw new Error(profile.adapter === 'api' ? 'API 成员没有本机文件工具,项目固定只读' : `${name} 的项目权限在 App 内设置`);
650
- if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name} 续接的是它自己的 agy 对话,项目权限沿用该对话所在项目,请在 agy 中设置`);
641
+ const facts = adapter_profile(profile.adapter);
642
+ // A CLI's project access is set per run; an App follows its own settings and an API member only reads.
643
+ if (!facts?.access_levels) throw new Error(!facts?.capabilities.project ? `${name} 没有本机文件工具,项目固定只读` : `${name} 的项目权限在 App 内设置`);
644
+ if (!facts.access_levels.includes(project_access)) throw new Error(facts.unsupported_hints?.[project_access] || `${facts.label} 不支持此档位`);
645
+ if (facts.own_project_when_bound && profile.session_id) throw new Error(`${name} 续接的是它自己的 ${facts.label} 对话,项目权限沿用该对话所在项目,请在原软件中设置`);
651
646
  if (project_access === 'write' && !(profile.cwd || config.cwd)) throw new Error('本房间没有指定项目目录,可写没有作用;请在新建房间时选择项目目录');
652
647
  const next = { ...config, participants: { ...config.participants, [name]: { ...profile, project_access } } };
653
648
  validate_config(next, dirname(path));
@@ -674,8 +669,9 @@ export async function room_member_allow(home, raw) {
674
669
  const config = configuration_snapshot(path).config;
675
670
  const profile = config.participants?.[name];
676
671
  if (!profile) throw new Error(`${name} 不由本机唤醒,权限由它自己的会话决定`);
677
- if (!['claude', 'antigravity_cli'].includes(profile.adapter)) throw new Error(`${name} 的软件不支持逐条放行,请改用更高的权限档位`);
678
- if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name} 续接的是它自己的 agy 对话,权限沿用该对话所在项目,请在 agy 中设置`);
672
+ const facts = adapter_profile(profile.adapter);
673
+ if (!facts?.per_tool_allow) throw new Error(`${name} 的软件不支持逐条放行,请改用更高的权限档位`);
674
+ if (facts.own_project_when_bound && profile.session_id) throw new Error(`${name} 续接的是它自己的 ${facts.label} 对话,权限沿用该对话所在项目,请在原软件中设置`);
679
675
  const allowed_tools = [...new Set([...(profile.allowed_tools || []), ...rules.map(rule => rule.trim())])];
680
676
  if (allowed_tools.length > MAX_ALLOWED_TOOLS) throw new Error(`每位成员最多放行 ${MAX_ALLOWED_TOOLS} 条规则`);
681
677
  const next = { ...config, participants: { ...config.participants, [name]: { ...profile, allowed_tools } } };
@@ -694,7 +690,7 @@ export async function room_member_adapter(home, raw) {
694
690
  const { input, path, config: registered } = await member_room_context(home, raw);
695
691
  const { name, adapter } = input;
696
692
  if (registered.mode !== 'free') throw new Error('讨论进行中不能切换;结束讨论后再切换,或暂停后在“接入”页修正');
697
- if (!CODEX_ADAPTERS.includes(adapter)) throw new Error('adapter 只能为 codex(App)或 codex_cli(CLI)');
693
+ if (!ADAPTER_IDS.some(other => same_session_family(other, adapter))) throw new Error('只能在共用会话的 App 与 CLI 接入之间切换');
698
694
  const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
699
695
  const member = room.free.members.find(item => item.name === name);
700
696
  if (member?.pending > 0 || ['dispatched', 'started'].includes(member?.execution?.status)) throw new Error(`${name} 正在处理请求,请等它完成或先停止`);
@@ -702,12 +698,14 @@ export async function room_member_adapter(home, raw) {
702
698
  try {
703
699
  const config = configuration_snapshot(path).config;
704
700
  const profile = config.participants?.[name];
705
- if (!CODEX_ADAPTERS.includes(profile?.adapter)) throw new Error(`${name} 不是本机唤醒的 Codex 成员`);
701
+ if (profile?.adapter !== adapter && !same_session_family(profile?.adapter, adapter)) throw new Error(`${name} 不是本机唤醒、能切换到该接入方式的成员`);
706
702
  if (profile.adapter === adapter) return { room_id: input.room_id, member: name, adapter };
707
703
  const context = { config, path };
708
704
  const next = { ...profile };
709
705
  switch_codex_adapter(context, name, next, adapter);
710
- if (adapter === 'codex') { delete next.model; delete next.effort; }
706
+ // An App chooses its own model and effort.
707
+ if (!adapter_profile(adapter).models) delete next.model;
708
+ if (!adapter_profile(adapter).efforts) delete next.effort;
711
709
  const updated = { ...config, participants: { ...config.participants, [name]: next } };
712
710
  validate_config(updated, dirname(path));
713
711
  atomic_json(path, updated);
@@ -715,19 +713,6 @@ export async function room_member_adapter(home, raw) {
715
713
  } finally { release(); }
716
714
  }
717
715
 
718
- /** A Codex desktop chat is resumed through the app, any other Codex session through the CLI. */
719
- function codex_session_adapter(session_id, env = process.env) {
720
- const sessions = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
721
- const file = session_files(sessions).find(path => path.endsWith(`-${session_id}.jsonl`));
722
- const meta = file ? readFileSync(file, 'utf8').split('\n', 1)[0] : '';
723
- return /"originator":"Codex Desktop"/.test(meta) ? 'codex' : 'codex_cli';
724
- }
725
-
726
- /** Where the Antigravity App or agy CLI keeps one conversation; the two keep separate stores. */
727
- function agy_conversation(adapter, session_id) {
728
- return join(homedir(), '.gemini', adapter === 'antigravity' ? 'antigravity' : 'antigravity-cli', 'conversations', `${session_id}.db`);
729
- }
730
-
731
716
  /**
732
717
  * Register a manual Claude, Codex or agy expert's own session as a hosted participant, so the service resumes it for later turns.
733
718
  * Returns the adapter used. Throws when the local software is not configured or the session is not found on this machine; the member then stays manual.
@@ -736,10 +721,11 @@ export function adopt_member_profile(home, path, name, session_id, client = 'cla
736
721
  const release = acquire_lock(join(dirname(path), 'configuration-edit'));
737
722
  try {
738
723
  const config = configuration_snapshot(path).config;
739
- const adapter = client === 'codex' ? codex_session_adapter(session_id)
740
- : client === 'antigravity' ? (existsSync(agy_conversation('antigravity', session_id)) ? 'antigravity' : 'antigravity_cli') : 'claude';
724
+ // The adapter whose store holds the session: a desktop chat is resumed through its App, others through the CLI.
725
+ const adapter = adoption_adapter(client, session_id);
726
+ if (!adapter) throw new Error(`${name}:本机不能接管 ${client} 的会话`);
741
727
  // An App conversation is reached through this room's own Sidecar, which the App starts as soon as it is configured.
742
- const sidecar = adapter === 'antigravity' ? { ipc_directory: join(dirname(path), 'agy-app-sidecar') } : {};
728
+ const sidecar = adapter_profile(adapter).location === 'ipc_directory' ? { ipc_directory: join(dirname(path), 'agy-app-sidecar') } : {};
743
729
  // A manually started member keeps its own session's permissions; the room's 只读 / 可写 does not apply to it.
744
730
  const input = { cwd: config.cwd, adopt: true, participants: { [name]: { adapter, session_id, project_access: 'own', ...sidecar } } };
745
731
  // Its model, effort, prompt and tools stay as the user set them in that session; machine defaults never override them.
@@ -812,17 +798,21 @@ export async function restore_room(home, raw) {
812
798
  const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
813
799
  if (registered_room_path(home, input.site, input.room_id)) throw new Error('本房间已在本机登记,不重复恢复');
814
800
  if (local.cwd && (!isAbsolute(local.cwd) || !existsSync(local.cwd) || !statSync(local.cwd).isDirectory())) throw new Error(`房间项目目录 ${local.cwd} 在本机不存在`);
815
- const keep_manual = room.free.members.filter(member => member.access === 'manual' && member.status !== 'removed' && platform_of(member.name) === 'Claude').map(member => member.name);
801
+ const keep_manual = room.free.members.filter(member => member.access === 'manual' && member.status !== 'removed'
802
+ && platform_adapters(platform_of(member.name)).some(id => ADAPTER_PROFILES[id].restore_manual)).map(member => member.name);
816
803
  const participants = {}, members = [];
817
804
  for (const [name, exported] of Object.entries(local.participants)) {
818
805
  try {
819
806
  const profile = participant_profile(name, {}, { cwd: local.cwd, adopt: true, participants: { [name]: exported } }, false);
820
- // An API member's binding lives in the old runtime directory, not in any software's session store, so it starts fresh.
821
- if (profile.adapter === 'api') delete profile.session_id;
807
+ // A member without a software session store (an API member) kept its binding in the old runtime directory, so it starts fresh.
808
+ if (!adapter_of(profile.adapter)?.session) delete profile.session_id;
822
809
  const resumed = profile.session_id ? borrowed_session(name, profile, local.cwd) : profile;
823
- if (profile.adapter === 'claude') {
810
+ if (adapter_profile(profile.adapter)?.restore_manual) {
824
811
  keep_manual.push(name);
825
- members.push({ name, status: 'manual', session_id: resumed.session_id || '', cwd: resumed.cwd || local.cwd || '' });
812
+ const cwd = resumed.cwd || local.cwd || '';
813
+ const command = resumed.session_id && adapter_profile(profile.adapter).resume_command?.(resumed.session_id);
814
+ members.push({ name, status: 'manual', session_id: resumed.session_id || '', cwd,
815
+ resume: command ? { command, cwd } : null });
826
816
  } else {
827
817
  participants[name] = resumed;
828
818
  members.push({ name, status: 'resumed', session_id: resumed.session_id || '' });
@@ -888,7 +878,7 @@ export async function fork_room(home, raw) {
888
878
  continue;
889
879
  }
890
880
  // Claude resumes a session only in its original directory, so the forked member works there too.
891
- const located = profile.adapter === 'claude' ? borrowed_session(name, profile, local.cwd) : profile;
881
+ const located = adapter_profile(profile.adapter)?.adopt === 'own_cwd' ? borrowed_session(name, profile, local.cwd) : profile;
892
882
  const branch = await branch_session(located, profile.session_id, (left_out[name] || []).filter(Number.isInteger), { always: true });
893
883
  const { session_id, ...forked } = located;
894
884
  participants[name] = forked;
@@ -1051,7 +1041,7 @@ function connection_view(context) {
1051
1041
 
1052
1042
  /** Switch only Codex's execution entrypoint, keeping its actual original conversation. */
1053
1043
  function switch_codex_adapter(context, name, profile, adapter) {
1054
- if (!CODEX_ADAPTERS.includes(profile.adapter) || !CODEX_ADAPTERS.includes(adapter)) throw new Error('本场仅支持 Codex App / CLI 切换');
1044
+ if (!same_session_family(profile.adapter, adapter)) throw new Error('本场仅支持共用会话的 App / CLI 接入之间切换');
1055
1045
  const current = validate_config(context.config, dirname(context.path));
1056
1046
  let session = profile.session_id;
1057
1047
  if (existsSync(join(current.directory, 'runtime.sqlite3'))) {
@@ -1072,8 +1062,8 @@ function corrected_configuration(context) {
1072
1062
  const config = structuredClone(context.config);
1073
1063
  for (const [name, patch] of Object.entries(context.input.participants)) {
1074
1064
  const profile = config.participants[name];
1075
- const field = profile?.adapter === 'antigravity' ? 'ipc_directory' : 'executable';
1076
- const fields = CODEX_ADAPTERS.includes(profile?.adapter) ? [field, 'adapter'] : [field];
1065
+ const field = adapter_profile(profile?.adapter)?.location || 'executable';
1066
+ const fields = ADAPTER_IDS.some(other => same_session_family(other, profile?.adapter)) ? [field, 'adapter'] : [field];
1077
1067
  if (!profile || !patch || typeof patch !== 'object' || Array.isArray(patch) || Object.keys(patch).some(key => !fields.includes(key))) throw new Error('本场修正仅支持软件路径、Sidecar 目录及 Codex App / CLI;参与者、项目及会话保持不变');
1078
1068
  validate_profile(name, patch);
1079
1069
  if (patch.adapter && patch.adapter !== profile.adapter) switch_codex_adapter(context, name, profile, patch.adapter);
@@ -1242,14 +1232,14 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1242
1232
  assert_shared_home(home);
1243
1233
  let result;
1244
1234
  if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
1245
- scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
1235
+ scheduler_providers: SCHEDULER_PROVIDERS, scheduler_modes: SCHEDULER_MODES, scheduler_default_mode: DEFAULT_SCHEDULER_MODE, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
1246
1236
  else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
1247
1237
  else if (positionals[0] === 'presets') result = input ? save_expert_presets(home, input) : expert_presets(home);
1248
1238
  else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
1249
1239
  else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
1250
1240
  else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
1251
1241
  else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
1252
- else if (positionals[0] === 'models' && input?.adapter === 'api') result = await api_models(home, input);
1242
+ else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
1253
1243
  else if (positionals[0] === 'models' && input) {
1254
1244
  const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
1255
1245
  result = await cached_software_models(home, { adapter: input.adapter, executable: input.executable || saved?.executable,
@@ -7,7 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
8
  import { parseArgs } from 'node:util';
9
9
 
10
- import { atomic_json } from './discussion_adapters.mjs';
10
+ import { atomic_json } from './files.mjs';
11
11
  import { acquire_lock } from './discussion_runner.mjs';
12
12
  import { assert_shared_home, main as service_main, service_home, service_pid, service_ready, start_service } from './discussion_service.mjs';
13
13
  import { detect_python, local_settings } from './discussion_setup.mjs';
@@ -7,7 +7,8 @@ import { DatabaseSync } from 'node:sqlite';
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
8
  import { parseArgs } from 'node:util';
9
9
  import { setTimeout as sleep } from 'node:timers/promises';
10
- import { setup_sidecar } from './discussion_adapters.mjs';
10
+ import { adapter_of, capabilities_of, validate_participant } from './adapters/index.mjs';
11
+ import { adapter_profile, same_session_family } from './adapters/profiles.mjs';
11
12
  import { read_api_key } from './discussion_api.mjs';
12
13
  import { access_levels, PROJECT_ACCESS, READ_RULE } from './expert_prompts.mjs';
13
14
  import { validate_scheduler } from './scheduler.mjs';
@@ -22,11 +23,9 @@ let rg_available;
22
23
  export function read_rule(adapter) {
23
24
  if (process.platform !== 'win32') return '';
24
25
  rg_available ??= spawnSync('rg', ['--version'], { stdio: 'ignore', windowsHide: true }).status === 0;
25
- return rg_available || adapter === 'codex_cli' ? READ_RULE : '';
26
+ return rg_available || capabilities_of(adapter).ripgrep ? READ_RULE : '';
26
27
  }
27
28
 
28
- const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravity_cli', 'api'];
29
- export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
30
29
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
31
30
  /** The shared prompt for rounds bridged without named follow-ups: experts answer and question each other. */
32
31
  export function peer_round_question(round) {
@@ -55,7 +54,7 @@ export function validate_config(input, base_directory) {
55
54
  // A free room of manual members runs here only for smart scheduling or a preset project directory, which need no local software.
56
55
  if (!config.participants || (!Object.keys(config.participants).length && !(config.mode === 'free' && (config.scheduler || config.cwd)))) throw new Error('请配置本机参与软件');
57
56
  for (const participant of Object.values(config.participants)) {
58
- if (participant.adapter === 'api') {
57
+ if (adapter_profile(participant.adapter)?.kind === 'api') {
59
58
  read_api_key(config.api_home, participant);
60
59
  for (const field of ['cwd', 'project_name', 'project_id', 'executable', 'effort']) delete participant[field];
61
60
  participant.project_access = 'read';
@@ -66,12 +65,11 @@ export function validate_config(input, base_directory) {
66
65
  participant.project_access = participant.project_access || config.project_access || 'read';
67
66
  // `own` is set only when the service takes over a manual member: it keeps its own session's permissions.
68
67
  if (![...PROJECT_ACCESS, 'own'].includes(participant.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
69
- if (participant.project_access !== 'own' && !access_levels(participant.adapter).includes(participant.project_access)) throw new Error('agy 无人值守不支持自动档,请选只读、可写或全放开');
68
+ if (participant.project_access !== 'own' && !access_levels(participant.adapter).includes(participant.project_access)) {
69
+ throw new Error(adapter_profile(participant.adapter)?.unsupported_hints?.[participant.project_access] || '该软件不支持此项目权限档位');
70
+ }
70
71
  if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
71
- if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
72
- if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
73
- if (participant.adapter !== 'antigravity' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
74
- if (participant.adapter === 'codex' && !participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
72
+ validate_participant(participant);
75
73
  }
76
74
  if (![undefined, 'free', 'brainstorm'].includes(config.mode)) throw new Error('房间模式只能为 free 或 brainstorm');
77
75
  if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
@@ -124,8 +122,8 @@ export class DiscussionStore {
124
122
  const next = entries[index];
125
123
  if (JSON.stringify(item) === JSON.stringify(next)) return true;
126
124
  const session = this.binding(item[0]).session_id || item[6];
127
- return item[0] === next[0] && item[1] !== next[1] && CODEX_ADAPTERS.includes(item[1])
128
- && CODEX_ADAPTERS.includes(next[1]) && session && next[6] === session && (!item[6] || item[6] === session)
125
+ return item[0] === next[0] && same_session_family(item[1], next[1])
126
+ && session && next[6] === session && (!item[6] || item[6] === session)
129
127
  && JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
130
128
  });
131
129
  // Experts invited during a run only add entries; every existing binding must stay as it was.
@@ -191,7 +189,10 @@ export async function main(args = process.argv.slice(2)) {
191
189
  }
192
190
  const config = load_config(values.config);
193
191
  if (positionals[0] === 'setup-sidecar') {
194
- for (const participant of Object.values(config.participants).filter(item => item.adapter === 'antigravity')) process.stdout.write(JSON.stringify(setup_sidecar(participant)) + '\n');
192
+ for (const participant of Object.values(config.participants)) {
193
+ const setup = adapter_of(participant.adapter)?.setup;
194
+ if (setup) process.stdout.write(JSON.stringify(setup(participant)) + '\n');
195
+ }
195
196
  return;
196
197
  }
197
198
  const run = await api(config, '/discussion');
@@ -8,7 +8,8 @@ import { DatabaseSync } from 'node:sqlite';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { parseArgs } from 'node:util';
10
10
  import { setTimeout as sleep } from 'node:timers/promises';
11
- import { atomic_json } from './discussion_adapters.mjs';
11
+ import { same_session_family } from './adapters/profiles.mjs';
12
+ import { atomic_json } from './files.mjs';
12
13
  import { DiscussionStore, acquire_lock, api, load_config, pause_if_running, service_drafts, validate_config } from './discussion_runner.mjs';
13
14
  import { FreeRoomRunner } from './free_runner.mjs';
14
15
 
@@ -222,8 +223,7 @@ class RoomWorker {
222
223
  }
223
224
  const codex_switches = Object.entries(this.config.participants).filter(([name, previous]) => {
224
225
  const next = updated.participants[name];
225
- return next && previous.adapter !== next.adapter
226
- && ['codex', 'codex_cli'].includes(previous.adapter) && ['codex', 'codex_cli'].includes(next.adapter);
226
+ return next && same_session_family(previous.adapter, next.adapter);
227
227
  });
228
228
  const codex_switch = codex_switches.length > 0;
229
229
  const discussion = codex_switch ? await api(this.config, '/discussion') : null;
@@ -6,7 +6,7 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:pa
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { parseArgs } from 'node:util';
8
8
 
9
- import { atomic_json } from './discussion_adapters.mjs';
9
+ import { atomic_json } from './files.mjs';
10
10
  import { launch_settings, save_launch_settings } from './discussion_launch.mjs';
11
11
  import { assert_shared_home, service_home } from './discussion_service.mjs';
12
12
  import { SKILLS, install_skills, remove_legacy_skills } from './discussion_skills.mjs';