ai_co_discuss 0.2.97 → 0.2.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,11 +9,12 @@ 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 } from './discussion_adapters.mjs';
13
- import { software_defaults, software_effort, software_models } from './discussion_models.mjs';
12
+ import { atomic_json, setup_sidecar } from './discussion_adapters.mjs';
13
+ import { session_settings, software_defaults, software_effort, software_models } from './discussion_models.mjs';
14
14
  import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
15
15
  import { SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
16
- import { BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT } from './expert_prompts.mjs';
16
+ 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';
17
18
  import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
18
19
  import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
19
20
 
@@ -53,9 +54,11 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
53
54
  codiscuss launch scheduler-check --file FILE
54
55
  codiscuss launch room-member --file FILE
55
56
  codiscuss launch room-member-access --file FILE
57
+ codiscuss launch room-member-allow --file FILE
56
58
  codiscuss launch room-member-adapter --file FILE
57
59
  codiscuss launch room-profiles --file FILE
58
60
  codiscuss launch room-restore --file FILE
61
+ codiscuss launch room-fork --file FILE
59
62
  codiscuss launch pick-directory
60
63
  codiscuss launch projects [--file FILE]
61
64
  codiscuss launch presets [--file FILE]
@@ -76,10 +79,12 @@ presets 读取本机的专家预设(含内置预设)与各平台默认描述
76
79
  machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
77
80
  admin-account 把在指定 site 注册的管理员用户名与密码保存到服务目录的 admin-accounts.json;须提供 site、username、password。
78
81
  room-member 为运行中的自由房间或讨论邀请、请回一位成员(讨论只接受本机唤醒的成员);须提供 site、room_id、token、action(add/rejoin)、name。
79
- room-member-access 修改一位本机唤醒成员的项目文件权限(project_access 为 read/write),下次投递起生效、保留原会话;须提供 site、room_id、token、name、project_access。
82
+ room-member-access 修改一位本机唤醒成员的项目权限(project_access 为 read/write/auto/full),下次投递起生效、保留原会话;须提供 site、room_id、token、name、project_access。
83
+ room-member-allow 把被拒绝的操作加入一位本机唤醒的 Claude Code 或 agy 成员的 allowed_tools,下次投递起生效;须提供 site、room_id、token、name、rules。
80
84
  room-member-adapter 把自由房间里空闲的 Codex 成员在 App(codex)与 CLI(codex_cli)间切换,保留原会话;须提供 site、room_id、token、name、adapter。
81
85
  room-profiles 导出房间的本机成员软件配置与实际会话 ID(不含凭据),供备份附带;须提供 site、room_id、token。
82
86
  room-restore 把导入房间的本机成员配置登记到本机并核对原会话存在,Claude Code 成员保持手动;须提供 site、room_id、token、local_members。
87
+ room-fork 把从原房间分支出的新房间登记到本机,各本机成员从原会话分叉、不含分支点之后的执行,原会话不变;须提供 site、room_id、token、source_room_id、source_token、executions({成员名: [排除的执行编号]})。
83
88
  JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
84
89
  或 room_id(已有房间)、participants(本次软件覆盖配置)。
85
90
  conversation 还须 current_session_id,当前会话接手首个主持任务。
@@ -250,9 +255,10 @@ export function launch_settings(home) {
250
255
 
251
256
  /**
252
257
  * Read the registered room's model choices, what each software selects for empty ones, and its smart scheduler, never connection secrets.
258
+ * `actual_model` / `actual_effort` (present only when known) are what each member's session last ran with, read from the software's own session log.
253
259
  * With `quick`, agy's default model is not queried and its entry is marked `defaults_pending`.
254
260
  */
255
- export async function room_models(home, raw, { read_defaults = software_defaults } = {}) {
261
+ export async function room_models(home, raw, { read_defaults = software_defaults, read_session = session_settings } = {}) {
256
262
  const input = launch_input({ ...raw, entry: 'web' });
257
263
  if (!input.room_id) throw new Error('读取本场模型须提供 room_id');
258
264
  const paths = read_registry(home).filter(path => {
@@ -262,13 +268,17 @@ export async function room_models(home, raw, { read_defaults = software_defaults
262
268
  if (paths.length > 1) throw new Error('本场存在多份登记配置,无法确定模型设置');
263
269
  const config = paths.length ? configuration_snapshot(paths[0]).config : {};
264
270
  const profiles = config.participants || {};
271
+ let sessions = {};
272
+ try { if (paths.length) sessions = bound_sessions(config, paths[0]); } catch { /* No readable runtime yet: fall back to borrowed sessions. */ }
265
273
  const participants = Object.fromEntries(await Promise.all(Object.entries(profiles).map(async ([name, profile]) => {
266
274
  const cli = ['codex_cli', 'claude', 'antigravity_cli', 'api'].includes(profile.adapter);
267
275
  // 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.
268
276
  const pending = input.quick === true && profile.adapter === 'antigravity_cli';
269
277
  const defaults = pending ? { model: '', effort: '' } : await read_defaults(profile);
278
+ const actual = profile.adapter === 'api' ? { model: '', effort: '' } : read_session(profile.adapter, sessions[name] || profile.session_id);
270
279
  return [name, { ...(pending ? { defaults_pending: true } : {}), adapter: profile.adapter, model: cli ? profile.model || '' : '',
271
280
  effort: EFFORT_LEVELS[profile.adapter] ? profile.effort || '' : '', default_model: defaults.model, default_effort: defaults.effort,
281
+ ...(actual.model ? { actual_model: actual.model } : {}), ...(actual.effort ? { actual_effort: actual.effort } : {}),
272
282
  project_access: profile.adapter === 'api' ? 'read' : profile.project_access || config.project_access || 'read',
273
283
  // An agy member resuming its own conversation keeps that conversation's project, so this tool cannot set its access.
274
284
  own_project: profile.adapter === 'antigravity_cli' && Boolean(profile.session_id) }];
@@ -323,7 +333,9 @@ export function save_launch_projects(home, input) {
323
333
  const MAX_EXPERT_PRESETS = 30;
324
334
  const MAX_PRESET_DESCRIPTION = 500;
325
335
  const MAX_PRESET_FIELD = 200;
326
- const PRESET_FIELDS = ['name', 'description', 'system_prompt', 'project_access', 'model', 'effort'];
336
+ // name is the instance a preset adds (platform-suffix); label is the free name shown for it, on the add row and in the room.
337
+ const PRESET_FIELDS = ['name', 'label', 'description', 'system_prompt', 'project_access', 'model', 'effort'];
338
+ const MAX_PRESET_LABEL = 32;
327
339
 
328
340
  /** The user's expert presets and per-platform default descriptions saved on this computer, plus the built-in presets. */
329
341
  export function expert_presets(home) {
@@ -340,14 +352,15 @@ export function save_expert_presets(home, input) {
340
352
  if (!Array.isArray(presets) || presets.length > MAX_EXPERT_PRESETS) throw new Error(`presets 必须为列表,最多 ${MAX_EXPERT_PRESETS} 个`);
341
353
  const names = new Set();
342
354
  for (const preset of presets) {
343
- if (!preset || typeof preset !== 'object' || Object.keys(preset).some(key => !PRESET_FIELDS.includes(key))) throw new Error('预设只能包含名称、描述、提示词、项目权限、模型与强度');
355
+ if (!preset || typeof preset !== 'object' || Object.keys(preset).some(key => !PRESET_FIELDS.includes(key))) throw new Error('预设只能包含名称、显示名、描述、提示词、项目权限、模型与强度');
344
356
  if (!platform_of(preset.name) || !clients_of(preset.name)) throw new Error(`预设名称须为“平台名”或“平台名-后缀”:${preset.name}`);
345
357
  if (names.has(preset.name)) throw new Error(`预设名称重复:${preset.name}`);
346
358
  names.add(preset.name);
347
359
  if (Object.values(preset).some(value => typeof value !== 'string')) throw new Error(`${preset.name} 的字段必须为文本`);
360
+ if ((preset.label || '').trim().length > MAX_PRESET_LABEL) throw new Error(`${preset.name} 的显示名最多 ${MAX_PRESET_LABEL} 字`);
348
361
  if ((preset.description || '').length > MAX_PRESET_DESCRIPTION) throw new Error(`${preset.name} 的描述最多 ${MAX_PRESET_DESCRIPTION} 字`);
349
362
  if ((preset.system_prompt || '').length > MAX_EXPERT_PROMPT) throw new Error(`${preset.name} 的提示词最多 ${MAX_EXPERT_PROMPT} 字`);
350
- if (preset.project_access && !['read', 'write'].includes(preset.project_access)) throw new Error(`${preset.name} 的项目权限须为 read 或 write`);
363
+ if (preset.project_access && !PROJECT_ACCESS.includes(preset.project_access)) throw new Error(`${preset.name} 的项目权限须为 read、write、auto 或 full`);
351
364
  if ([preset.model, preset.effort].some(value => (value || '').length > MAX_PRESET_FIELD)) throw new Error(`${preset.name} 的模型或强度过长`);
352
365
  }
353
366
  if (!templates || typeof templates !== 'object' || Array.isArray(templates) || Object.entries(templates).some(([platform, text]) =>
@@ -401,7 +414,7 @@ function launch_input(input) {
401
414
  if (!['conversation', 'web'].includes(value.entry)) throw new Error('entry 必须为 conversation 或 web');
402
415
  if (value.entry === 'conversation' && (typeof value.current_session_id !== 'string' || !value.current_session_id.trim())) throw new Error('对话入口须提供当前实际会话的 current_session_id');
403
416
  if (value.cwd && (typeof value.cwd !== 'string' || !isAbsolute(value.cwd))) throw new Error('cwd 必须为完整绝对路径');
404
- if (value.project_access && !['read', 'write'].includes(value.project_access)) throw new Error('project_access 只能为 read 或 write');
417
+ if (value.project_access && !PROJECT_ACCESS.includes(value.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
405
418
  if (value.room_id && !/^[A-Za-z0-9_-]+$/.test(value.room_id)) throw new Error('房间编号无效');
406
419
  if (!value.room_id && !value.room?.submission_id) throw new Error('新房间须提供 room.submission_id;重试沿用原编号');
407
420
  if (value.room?.mode === 'free') {
@@ -518,8 +531,8 @@ export function borrowed_session(name, profile, room_cwd, env = process.env) {
518
531
  if (!session_files(sessions).some(path => path.endsWith(`-${id}.jsonl`))) throw new Error(`${name}:本机找不到 Codex 会话 ${id},请核对会话 ID`);
519
532
  return profile;
520
533
  }
521
- if (profile.adapter === 'antigravity_cli') {
522
- if (!existsSync(join(homedir(), '.gemini', 'antigravity-cli', 'conversations', `${id}.db`))) throw new Error(`${name}:本机找不到 agy 会话 ${id},请核对会话 ID`);
534
+ if (['antigravity', 'antigravity_cli'].includes(profile.adapter)) {
535
+ if (!existsSync(agy_conversation(profile.adapter, id))) throw new Error(`${name}:本机找不到 agy 会话 ${id},请核对会话 ID`);
523
536
  return profile;
524
537
  }
525
538
  if (profile.adapter !== 'claude') throw new Error(`${name}:只有 Codex、Claude 与 agy 可以接入已有会话`);
@@ -623,12 +636,14 @@ const ACCESS_ADAPTERS = ['codex_cli', 'claude', 'antigravity_cli'];
623
636
  export async function room_member_access(home, raw) {
624
637
  const { input, path } = await member_room_context(home, raw);
625
638
  const { name, project_access } = input;
626
- if (!['read', 'write'].includes(project_access)) throw new Error('project_access 只能为 read 或 write');
639
+ if (!PROJECT_ACCESS.includes(project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
627
640
  const release = acquire_lock(join(dirname(path), 'configuration-edit'));
628
641
  try {
629
642
  const config = configuration_snapshot(path).config;
630
643
  const profile = config.participants?.[name];
631
644
  if (!profile) throw new Error(`${name} 不由本机唤醒,项目权限由它自己的会话决定`);
645
+ if (profile.project_access === 'own') throw new Error(`${name} 是手动接入的成员,权限沿用它自己会话的软件设置`);
646
+ if (!access_levels(profile.adapter).includes(project_access)) throw new Error('agy 无人值守不支持自动档,请选只读、可写或完全放开');
632
647
  if (!ACCESS_ADAPTERS.includes(profile.adapter)) throw new Error(profile.adapter === 'api' ? 'API 成员没有本机文件工具,项目固定只读' : `${name} 的项目权限在 App 内设置`);
633
648
  if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name} 续接的是它自己的 agy 对话,项目权限沿用该对话所在项目,请在 agy 中设置`);
634
649
  if (project_access === 'write' && !(profile.cwd || config.cwd)) throw new Error('本房间没有指定项目目录,可写没有作用;请在新建房间时选择项目目录');
@@ -639,6 +654,35 @@ export async function room_member_access(home, raw) {
639
654
  } finally { release(); }
640
655
  }
641
656
 
657
+ const MAX_ALLOWED_TOOLS = 50;
658
+
659
+ /**
660
+ * Allow operations a woken member's software refused, by adding the rules the runner reported with them to its
661
+ * `allowed_tools`: Claude passes them to `--allowedTools`, agy adds them to its discussion project's grants. Like the
662
+ * project access, they apply from the member's next delivery and keep its session.
663
+ */
664
+ export async function room_member_allow(home, raw) {
665
+ const { input, path } = await member_room_context(home, raw);
666
+ const { name, rules } = input;
667
+ if (!Array.isArray(rules) || !rules.length || rules.some(rule => typeof rule !== 'string' || !rule.trim() || rule.length > 300)) {
668
+ throw new Error('rules 须为要放行的规则列表');
669
+ }
670
+ const release = acquire_lock(join(dirname(path), 'configuration-edit'));
671
+ try {
672
+ const config = configuration_snapshot(path).config;
673
+ const profile = config.participants?.[name];
674
+ if (!profile) throw new Error(`${name} 不由本机唤醒,权限由它自己的会话决定`);
675
+ if (!['claude', 'antigravity_cli'].includes(profile.adapter)) throw new Error(`${name} 的软件不支持逐条放行,请改用更高的权限档位`);
676
+ if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name} 续接的是它自己的 agy 对话,权限沿用该对话所在项目,请在 agy 中设置`);
677
+ const allowed_tools = [...new Set([...(profile.allowed_tools || []), ...rules.map(rule => rule.trim())])];
678
+ if (allowed_tools.length > MAX_ALLOWED_TOOLS) throw new Error(`每位成员最多放行 ${MAX_ALLOWED_TOOLS} 条规则`);
679
+ const next = { ...config, participants: { ...config.participants, [name]: { ...profile, allowed_tools } } };
680
+ validate_config(next, dirname(path));
681
+ atomic_json(path, next);
682
+ return { room_id: input.room_id, member: name, allowed_tools };
683
+ } finally { release(); }
684
+ }
685
+
642
686
  /**
643
687
  * Switch an idle free-room Codex member between the App and the CLI, keeping its conversation: the App resumes the
644
688
  * bound chat by its session, and the CLI resumes it only after the App has released it. Model and effort are chosen
@@ -677,17 +721,27 @@ function codex_session_adapter(session_id, env = process.env) {
677
721
  return /"originator":"Codex Desktop"/.test(meta) ? 'codex' : 'codex_cli';
678
722
  }
679
723
 
724
+ /** Where the Antigravity App or agy CLI keeps one conversation; the two keep separate stores. */
725
+ function agy_conversation(adapter, session_id) {
726
+ return join(homedir(), '.gemini', adapter === 'antigravity' ? 'antigravity' : 'antigravity-cli', 'conversations', `${session_id}.db`);
727
+ }
728
+
680
729
  /**
681
- * Register a manual Claude or Codex expert's own session as a hosted participant, so the service resumes it for later turns.
730
+ * Register a manual Claude, Codex or agy expert's own session as a hosted participant, so the service resumes it for later turns.
682
731
  * 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.
683
732
  */
684
733
  export function adopt_member_profile(home, path, name, session_id, client = 'claude', member_token = '') {
685
734
  const release = acquire_lock(join(dirname(path), 'configuration-edit'));
686
735
  try {
687
736
  const config = configuration_snapshot(path).config;
688
- const adapter = client === 'codex' ? codex_session_adapter(session_id) : client === 'antigravity' ? 'antigravity_cli' : 'claude';
689
- const input = { cwd: config.cwd, adopt: true, participants: { [name]: { adapter, session_id } } };
737
+ const adapter = client === 'codex' ? codex_session_adapter(session_id)
738
+ : client === 'antigravity' ? (existsSync(agy_conversation('antigravity', session_id)) ? 'antigravity' : 'antigravity_cli') : 'claude';
739
+ // An App conversation is reached through this room's own Sidecar, which the App starts as soon as it is configured.
740
+ const sidecar = adapter === 'antigravity' ? { ipc_directory: join(dirname(path), 'agy-app-sidecar') } : {};
741
+ // A manually started member keeps its own session's permissions; the room's 只读 / 可写 does not apply to it.
742
+ const input = { cwd: config.cwd, adopt: true, participants: { [name]: { adapter, session_id, project_access: 'own', ...sidecar } } };
690
743
  const profile = borrowed_session(name, participant_profile(name, launch_settings(home).participants, input, false), config.cwd);
744
+ if (sidecar.ipc_directory) setup_sidecar(sidecar);
691
745
  // A free room delivers with each member's own credential; the browser-made one is not kept locally, so the owner issues a new one.
692
746
  const next = { ...config, participants: { ...config.participants, [name]: profile },
693
747
  ...(member_token ? { members: { ...config.members, [name]: { token: member_token, pending: '' } } } : {}) };
@@ -772,6 +826,15 @@ export async function restore_room(home, raw) {
772
826
  } catch (error) { members.push({ name, status: 'failed', error: error.message }); }
773
827
  }
774
828
  if (!Object.keys(participants).length && !local.cwd && !local.scheduler) return { room_id: input.room_id, registered: false, members };
829
+ register_copied_room(home, input, local, participants, keep_manual);
830
+ return { room_id: input.room_id, registered: true, members };
831
+ }
832
+
833
+ /**
834
+ * Register an imported or forked free room's local members under a fresh launch directory; `seed` writes the new
835
+ * runtime's bindings before the service can see the room. Returns the runtime directory.
836
+ */
837
+ function register_copied_room(home, input, local, participants, keep_manual, seed = () => {}) {
775
838
  const key = createHash('sha256').update(`${input.site}/${input.room_id}`).digest('hex');
776
839
  // A leftover directory of a deleted room with the same ID would carry its old bindings, so a restore never reuses one.
777
840
  const base = join(home, 'launches', key), directory = existsSync(base) ? `${base}-${Date.now()}` : base;
@@ -783,7 +846,63 @@ export async function restore_room(home, raw) {
783
846
  validate_config(config, directory);
784
847
  mkdirSync(directory, { recursive: true });
785
848
  atomic_json(join(directory, 'config.json'), config);
849
+ seed(config.data_directory);
786
850
  register_room(home, join(directory, 'config.json'));
851
+ return config.data_directory;
852
+ }
853
+
854
+ // Software whose session a fork branches; other local members start a new conversation in the forked room.
855
+
856
+ /**
857
+ * Register a room forked from another on this machine: each local member continues from its own session as it stood
858
+ * at the fork point, without the executions the fork leaves out. The source room's sessions are never changed.
859
+ * Requires both rooms' owner credentials; members the new room keeps manual or online are not registered here.
860
+ * Returns `{room_id, registered, members: [{name, status: forked|fresh|failed, error?}]}`.
861
+ */
862
+ export async function fork_room(home, raw) {
863
+ const input = launch_input({ ...raw, entry: 'web' });
864
+ if (!input.room_id || typeof input.token !== 'string' || !input.token.trim() || typeof raw.source_room_id !== 'string' || typeof raw.source_token !== 'string') {
865
+ throw new Error('分支须提供新房间与原房间的编号和控制凭据');
866
+ }
867
+ const local = (await room_profiles(home, { site: input.site, room_id: raw.source_room_id, token: raw.source_token })).local_members;
868
+ if (!local) return { room_id: input.room_id, registered: false, members: [] };
869
+ if (local.mode !== 'free') throw new Error('只有协作房间可以分支');
870
+ await site_request(input.site, `/api/rooms/${input.room_id}/inbox`, input.token);
871
+ const room = await site_request(input.site, `/api/rooms/${input.room_id}`, input.token);
872
+ if (registered_room_path(home, input.site, input.room_id)) throw new Error('分支房间已在本机登记');
873
+ const runners = new Set(room.free.members.filter(member => member.access === 'runner' && member.status !== 'removed').map(member => member.name));
874
+ const left_out = raw.executions && typeof raw.executions === 'object' ? raw.executions : {};
875
+ const participants = {}, members = [], bindings = {}, forks = {};
876
+ for (const [name, exported] of Object.entries(local.participants)) {
877
+ if (!runners.has(name)) continue;
878
+ try {
879
+ const profile = participant_profile(name, {}, { cwd: local.cwd, adopt: true, participants: { [name]: exported } }, false);
880
+ if (!profile.session_id || !BRANCHABLE_ADAPTERS.includes(profile.adapter)) {
881
+ delete profile.session_id;
882
+ participants[name] = profile;
883
+ members.push({ name, status: 'fresh' });
884
+ continue;
885
+ }
886
+ // Claude resumes a session only in its original directory, so the forked member works there too.
887
+ const located = profile.adapter === 'claude' ? borrowed_session(name, profile, local.cwd) : profile;
888
+ const branch = await branch_session(located, profile.session_id, (left_out[name] || []).filter(Number.isInteger), { always: true });
889
+ const { session_id, ...forked } = located;
890
+ participants[name] = forked;
891
+ bindings[name] = branch.session_id;
892
+ if (branch.fork) forks[name] = branch.fork;
893
+ members.push({ name, status: branch.session_id ? 'forked' : 'fresh' });
894
+ } catch (error) { members.push({ name, status: 'failed', error: error.message }); }
895
+ }
896
+ if (!Object.keys(participants).length && !local.cwd && !local.scheduler) return { room_id: input.room_id, registered: false, members };
897
+ register_copied_room(home, input, local, participants, [], data_directory => {
898
+ const store = new DiscussionStore(data_directory);
899
+ try {
900
+ for (const [name, session_id] of Object.entries(bindings)) store.bind(name, session_id);
901
+ // A Claude fork happens on the member's next turn; the runner keeps it pending until that session exists.
902
+ for (const [name, fork] of Object.entries(forks)) store.db.prepare('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
903
+ .run(`session-fork:${name}`, JSON.stringify(fork));
904
+ } finally { store.close(); }
905
+ });
787
906
  return { room_id: input.room_id, registered: true, members };
788
907
  }
789
908
 
@@ -1108,9 +1227,11 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1108
1227
  }
1109
1228
  else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
1110
1229
  else if (positionals[0] === 'room-member-access' && input) result = await room_member_access(home, input);
1230
+ else if (positionals[0] === 'room-member-allow' && input) result = await room_member_allow(home, input);
1111
1231
  else if (positionals[0] === 'room-member-adapter' && input) result = await room_member_adapter(home, input);
1112
1232
  else if (positionals[0] === 'room-profiles' && input) result = await room_profiles(home, input);
1113
1233
  else if (positionals[0] === 'room-restore' && input) result = await restore_room(home, input);
1234
+ else if (positionals[0] === 'room-fork' && input) result = await fork_room(home, input);
1114
1235
  else if (positionals[0] === 'pick-directory') result = await pick_directory();
1115
1236
  else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
1116
1237
  else if (positionals[0] === 'admin-account' && input) result = (await import('./admin_account.mjs')).save_admin_account(home, input);
@@ -1,14 +1,22 @@
1
1
  /** Discover model selectors from the installed software without starting an AI turn. */
2
2
  import { spawn } from 'node:child_process';
3
- import { readFileSync } from 'node:fs';
3
+ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
4
4
  import { isAbsolute, join } from 'node:path';
5
5
  import { homedir, tmpdir } from 'node:os';
6
6
  import { createInterface } from 'node:readline';
7
+ import { DatabaseSync } from 'node:sqlite';
7
8
 
8
9
  const QUERY_TIMEOUT_MS = 30000;
9
10
  const MAX_OUTPUT_BYTES = 1024 * 1024;
10
11
  const SOURCES = { codex_cli: 'codex-app-server', claude: 'claude-cli', antigravity_cli: 'antigravity-cli' };
11
12
  const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/;
13
+ const SESSION_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
14
+ // Only the end of a session log is read; the latest request is there and long sessions stay cheap.
15
+ const SESSION_TAIL_BYTES = 4 * 1024 * 1024;
16
+ // Claude Code logs side queries (titles, compaction, subagents) too; only these sources are the member's own turns.
17
+ const CLAUDE_MAIN_SOURCES = ['sdk', 'repl_main_thread'];
18
+ // agy stores its generation settings as protobuf; field 28 (tag bytes 0xe2 0x01) holds the model actually used.
19
+ const AGY_MODEL_TAG = [0xe2, 0x01];
12
20
 
13
21
  /** Read the string keys of a TOML file by table; Codex settings need nothing richer. */
14
22
  function toml_strings(text) {
@@ -42,6 +50,86 @@ export function cli_defaults(adapter, { env = process.env, home = homedir() } =
42
50
  return { model: '', effort: '' };
43
51
  }
44
52
 
53
+ /** The lines at the end of a file, newest first; a line cut at the start of the tail fails to parse and is skipped. */
54
+ function tail_lines(file) {
55
+ const fd = openSync(file, 'r');
56
+ try {
57
+ const size = fstatSync(fd).size, length = Math.min(size, SESSION_TAIL_BYTES), buffer = Buffer.alloc(length);
58
+ readSync(fd, buffer, 0, length, size - length);
59
+ return buffer.toString('utf8').split('\n').reverse();
60
+ } finally { closeSync(fd); }
61
+ }
62
+
63
+ /** Parse the newest log record that `pick` turns into settings. */
64
+ function newest_record(file, marker, pick) {
65
+ for (const line of tail_lines(file)) {
66
+ if (!line.includes(marker)) continue;
67
+ try { const found = pick(JSON.parse(line)); if (found) return found; } catch { /* A cut or foreign line says nothing. */ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** The model string after agy's model tag in one protobuf blob. */
73
+ function agy_model(blob) {
74
+ const bytes = Buffer.from(blob);
75
+ for (let at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG)); at >= 0; at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG), at + 1)) {
76
+ const length = bytes[at + 2], text = bytes.subarray(at + 3, at + 3 + length).toString('latin1');
77
+ if (length && text.length === length && MODEL_PATTERN.test(text)) return text;
78
+ }
79
+ return '';
80
+ }
81
+
82
+ /** Find a session's log file, or '' when this machine never stored it. */
83
+ function session_file(adapter, session_id, env, home) {
84
+ if (['codex', 'codex_cli'].includes(adapter)) {
85
+ const sessions = join(env.CODEX_HOME || join(home, '.codex'), 'sessions');
86
+ if (!existsSync(sessions)) return '';
87
+ const found = readdirSync(sessions, { recursive: true }).map(String).find(path => path.endsWith(`-${session_id}.jsonl`));
88
+ return found ? join(sessions, found) : '';
89
+ }
90
+ if (adapter === 'claude') {
91
+ const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
92
+ if (!existsSync(projects)) return '';
93
+ const found = readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync);
94
+ return found || '';
95
+ }
96
+ if (['antigravity', 'antigravity_cli'].includes(adapter)) {
97
+ const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
98
+ return existsSync(file) ? file : '';
99
+ }
100
+ return '';
101
+ }
102
+
103
+ /**
104
+ * The model and effort a session last ran with, read from the software's own session log; no AI turn is started.
105
+ * These are each software's internal formats, so anything unreadable yields empty strings rather than an error.
106
+ */
107
+ export function session_settings(adapter, session_id, { env = process.env, home = homedir() } = {}) {
108
+ const none = { model: '', effort: '' };
109
+ if (typeof session_id !== 'string' || !SESSION_PATTERN.test(session_id)) return none;
110
+ try {
111
+ const file = session_file(adapter, session_id, env, home);
112
+ if (!file) return none;
113
+ if (adapter === 'claude') {
114
+ return newest_record(file, '"type":"api-request"', record => record.type === 'api-request' && CLAUDE_MAIN_SOURCES.includes(record.querySource)
115
+ && typeof record.params?.model === 'string' ? { model: record.params.model, effort: String(record.params.output_config?.effort || '') } : null) || none;
116
+ }
117
+ if (file.endsWith('.jsonl')) {
118
+ return newest_record(file, '"turn_context"', record => record.type === 'turn_context' && typeof record.payload?.model === 'string'
119
+ ? { model: record.payload.model, effort: String(record.payload.effort || record.payload.collaboration_mode?.settings?.reasoning_effort || '') } : null) || none;
120
+ }
121
+ const db = new DatabaseSync(file, { readOnly: true });
122
+ try {
123
+ for (const { data } of db.prepare('SELECT data FROM gen_metadata ORDER BY idx DESC LIMIT 5').all()) {
124
+ const model = data ? agy_model(data) : '';
125
+ // agy names the effort inside the model ID (gemini-…-medium).
126
+ if (model) return { model, effort: '' };
127
+ }
128
+ } finally { db.close(); }
129
+ } catch { /* Missing, locked or changed formats mean nothing is known. */ }
130
+ return none;
131
+ }
132
+
45
133
  /** Return only model identifiers, display names and supported effort values. */
46
134
  function model_entry(id, label, efforts) {
47
135
  if (typeof id !== 'string' || !MODEL_PATTERN.test(id)) return null;
@@ -7,19 +7,23 @@ 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 { agy_command_rule, atomic_json, deliver_task, remove_agy_projects, SegmentLog, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
10
+ import { agy_command_rule, atomic_json, deliver_task, remove_agy_projects, runs_commands, SegmentLog, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
11
11
  import { read_api_key } from './discussion_api.mjs';
12
- import { with_expert_prompt } from './expert_prompts.mjs';
12
+ import { access_levels, PROJECT_ACCESS, project_rule, READ_RULE, with_expert_prompt } from './expert_prompts.mjs';
13
13
  import { decide_interjection, decide_next_speaker, decide_round, validate_scheduler } from './scheduler.mjs';
14
14
 
15
15
  const POLL_MS = 2000;
16
16
  /** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
17
17
  let rg_available;
18
- /** Windows PowerShell mis-decodes UTF-8 files, so point the AI at rg only when it is installed. */
19
- function windows_rg() {
20
- if (process.platform !== 'win32') return false;
18
+ /**
19
+ * Windows PowerShell mis-decodes UTF-8 files, so point the AI at rg when its commands can run it; free rooms and
20
+ * discussions share it. Codex CLI puts its own bundled rg on its commands' PATH (tested 0.163), even where this
21
+ * machine's PATH has none; other software gets the rule only when rg is installed here.
22
+ */
23
+ export function read_rule(adapter) {
24
+ if (process.platform !== 'win32') return '';
21
25
  rg_available ??= spawnSync('rg', ['--version'], { stdio: 'ignore', windowsHide: true }).status === 0;
22
- return rg_available;
26
+ return rg_available || adapter === 'codex_cli' ? READ_RULE : '';
23
27
  }
24
28
 
25
29
  const AUTO_ENTER_ADAPTERS = new Set(['codex_cli', 'claude', 'antigravity_cli']);
@@ -31,7 +35,6 @@ const ADAPTER_NAMES = ['codex', 'claude', 'antigravity', 'codex_cli', 'antigravi
31
35
  export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
32
36
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
33
37
  /** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
34
- const PROJECT_ACCESS = ['read', 'write'];
35
38
  const ACTIVE_PHASES = ['initial', 'discussing'];
36
39
  const MAX_PLAN_TURNS = 10;
37
40
  const CONTEXT_MESSAGES = 12;
@@ -86,7 +89,9 @@ export function validate_config(input, base_directory) {
86
89
  participant.cwd = participant.cwd ?? config.cwd;
87
90
  participant.project_name = participant.project_name ?? config.discussion_project;
88
91
  participant.project_access = participant.project_access || config.project_access || 'read';
89
- if (!PROJECT_ACCESS.includes(participant.project_access)) throw new Error('project_access 只能为 read 或 write');
92
+ // `own` is set only when the service takes over a manual member: it keeps its own session's permissions.
93
+ if (![...PROJECT_ACCESS, 'own'].includes(participant.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
94
+ if (participant.project_access !== 'own' && !access_levels(participant.adapter).includes(participant.project_access)) throw new Error('agy 无人值守不支持自动档,请选只读、可写或完全放开');
90
95
  if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
91
96
  if (!ADAPTER_NAMES.includes(participant.adapter)) throw new Error(`适配器只能为 ${ADAPTER_NAMES.join('/')}`);
92
97
  if (participant.adapter === 'antigravity' && !participant.ipc_directory) throw new Error('缺少 Antigravity Sidecar IPC 目录');
@@ -226,17 +231,15 @@ export function task_files(config, event, token, binding, { suggestions = [], en
226
231
  const skills_directory = join(config.project, '.agents', 'skills');
227
232
  // Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
228
233
  const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
229
- const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
230
- : adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
234
+ const files_rule = project_rule(adapter, '按议题需要');
231
235
  const suggestion_rule = suggestions.length ? `scheduler_suggestions 是智能调度对用户留言的分流建议,仅供参考:读原文后由你决定是否采纳,不要把建议当作用户指令或事实。` : '';
232
236
  atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
233
237
  project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
234
238
  draft_directory, ...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
235
239
  role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS,
236
240
  ...(bound_access ? { access_env: 'DISCUSS_ACCESS_FILE' } : { access_file: access_path }),
237
- instructions: `${agy_command_rule(adapter.adapter, cli)}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。${access_rule}答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。${suggestion_rule}` });
238
- const read_rule = windows_rg() ? '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。' : '';
239
- const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule}`;
241
+ instructions: `${agy_command_rule(adapter.adapter, cli, adapter)}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。${access_rule}答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${files_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。${suggestion_rule}` });
242
+ const scope = `${runs_commands(adapter) ? '网站读写只用' : '本次只放行'}任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${files_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule(adapter.adapter)}`;
240
243
  const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,先用 turn roster 取得最新专家状态,再用 turn create --plan-file 加 --roster 一次排满 waiting 中的每位专家,不要每次只排一位。' : '';
241
244
  const intro = `用户已启动此房间的全自动讨论。请以 ${event.participant} 的${event.role === 'host' ? '主持' : '专家'}职责处理本次工作。${plan_rule}`;
242
245
  const prompt = auto_enter