ai_co_discuss 0.3.82 → 0.3.83

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.
package/docs/runner.md CHANGED
@@ -113,8 +113,9 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
113
113
 
114
114
  工作流把一种多人协作的做法存下来,供别的房间复用:一份给主持人读的做法说明(SKILL.md),加一组 Agent 卡片(每张就是一个专家预设)。建房或普通邀请时在“工作流”一行选中它,只供查看和编辑,不添加成员,也不随建房启动。建房后,在输入框要求主持人启动(打 `/` 选工作流可以直接写好这句请求,权限见下文);主持人此后按说明组织工作。它只带来做法和成员,不保证工序,具体怎么做仍由主持人判断,你随时可以 @ 任何成员。
115
115
 
116
- - **保存**:在“工作流”一行点“添加”,选“新建”,或导入别人分享的 zip 或文件夹(手机、平板上同样可用)。导入的内容先在编辑页预览,点保存才写入。选中一个工作流后,下方显示它的描述,可“查看说明”(做法说明与成员卡片)或“编辑”,删除在编辑页底部。编辑页可以“从当前房间成员导入”卡片,提示词保持原文。想让主持人起草,打开下面的“允许主持人自建工作流”。
117
- - **让主持人自己建**:建房时(第 3 节“协作方式”)或在“成员与设置 → 协作设置”勾选“允许主持人自建工作流”后,直接对主持人说要什么分工(或“把这个房间的做法存成工作流”),它会自己保存、在本房间启用,并邀请工作流里的成员,接着就按流程派活,不需要你导入或添加成员。之后让它改流程,改动从成员下一次执行起生效。打开这个开关后,也可以在输入框输入 `/` 选一个已保存的工作流,输入框里会写好“启用工作流「名称」(ID),”,补上要做的事发给主持人,它就启用这个工作流并带来成员(没打开时该项灰显)。主持人带来的成员项目权限最高为可写;只有本地软件担任的主持人能这样做,API 主持人不行。
116
+ - **保存**:在“工作流”一行点“添加”,选“新建”,或导入别人分享的 zip 或文件夹(手机、平板上同样可用)。导入的内容先在编辑页预览,点保存才写入。选中一个工作流后,下方显示它的描述,可“查看说明”(做法说明与成员卡片)或“编辑”,删除在编辑页底部。编辑页可以“从当前房间成员导入”卡片,提示词保持原文。想让主持人起草,打开下面的“主持人完整权限”。
117
+ - **让主持人自己建**:建房时(第 3 节“协作方式”)或在“成员与设置 → 协作设置”勾选“主持人完整权限”后,直接对主持人说要什么分工(或“把这个房间的做法存成工作流”),它会自己保存、在本房间启用,并邀请工作流里的成员,接着就按流程派活,不需要你导入或添加成员。之后让它改流程,改动从成员下一次执行起生效。打开这个开关后,也可以在输入框输入 `/` 选一个已保存的工作流,输入框里会写好“启用工作流「名称」(ID),”,补上要做的事发给主持人,它就启用这个工作流并带来成员(没打开时该项灰显)。主持人带来的成员项目权限最高为可写;只有本地软件担任的主持人能这样做,API 主持人不行。
118
+ - **主持人完整权限的其他控制**:同一个开关打开后,主持人会自己处理房间里的问题、持续推进:成员跑偏时插话、卡住时打断或重试、模型不可用时换模型、无法恢复时换人;你也可以直接说“停下写手”“全部停下”“下一个让批评家先说”“请一位 Codex 进来只读评审”“把记录员请出去”“开个 Goal 把剩下几集写完”“暂停讨论”。邀请只用本机已配置的软件,项目权限最高可写;它自己开的 Goal 用你的默认预算(不限时为 8 次续跑、90 分钟)。成员连续 10 分钟没有新输出时,系统会提醒主持人检查;主持人派出长任务时还可以给自己设定时检查(每 5–120 分钟一次,房间空闲后自动结束)。它也能撤回自己派出的活,正在循环的接力会立即停下。以下仍由你决定:对主持人自己操作或更换主持人、批准超出上限的轮数、高于可写的权限或放行工具、改项目目录、撤回你的消息、私聊。API 主持人没有命令行,不能这样做。
118
119
  - **分享**:工作流存在本机服务目录的 `workflows` 文件夹,一个工作流一个文件夹;编辑页可复制路径,把整个文件夹(或打成 zip)发给对方即可分享。导入时会列出每张卡片的提示词和项目权限;要求写文件的卡片默认按只读导入,逐张勾选后才放开。
119
120
  - **修改与结束**:修改工作流会作用于所有启用它的房间,成员下一次执行就按新内容;新增的卡片不会自动进入房间,主持人用到时会自己补上,也可以在右栏工作流一行点刷新图标立即补齐(删掉的卡片,其成员留作普通成员;与库一致时不显示该图标);工作流卡片的提示词只读,在“工作流”一行点“编辑”修改。工作流进行中时,主持人只在请求和它的用途相符(或你说按工作流做)时才用,其他请求照常处理。用完在“成员与设置 → 协作设置”的工作流一行点请离成员图标(或逐个让成员离开):工作流带来的成员离开(主持人保留),正在进行的工作随之停止,工作流结束,右栏这一块消失;成员会话保留,下次在输入框打 `/` 选它就把原成员请回来,接着原来的上下文。换工作流时直接选另一个,原来的成员留作普通成员。
120
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.82",
3
+ "version": "0.3.83",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
package/runner/discuss.py CHANGED
@@ -234,7 +234,7 @@ def room_commands(subcommands, registry):
234
234
  elif action in ("view", "brief"):
235
235
  parser.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
236
236
  events = add_leaf(subcommands, registry, "room events",
237
- "房主读取房间事件记录:用户与程序的每次写入(含被拒的)及其引起的状态变化,按发生顺序,用于排查问题;默认最新 500 条", True)
237
+ "房主或当前主持人读取房间事件记录(主持人看不到私聊与消息正文):用户与程序的每次写入(含被拒的)及其引起的状态变化,按发生顺序,用于排查问题;默认最新 500 条", True)
238
238
  paging = events.add_mutually_exclusive_group()
239
239
  paging.add_argument("--after", type=non_negative_int, help="只读此事件编号之后的记录")
240
240
  paging.add_argument("--before", type=non_negative_int, help="读此事件编号之前的一页;按返回的 more 继续向前")
@@ -84,6 +84,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
84
84
  codiscuss launch machine-link --file FILE
85
85
  codiscuss launch workflow-adopt (--draft FILE | --id WORKFLOW) [--access-file FILE]
86
86
  codiscuss launch workflow-join [--card ID[,ID]] [--access-file FILE]
87
+ codiscuss launch host-control ACTION [选项] [--access-file FILE]
87
88
  codiscuss launch workflow-check (--dir DIR | --draft FILE)
88
89
  codiscuss launch workflow-save (--dir DIR | --draft FILE)
89
90
 
@@ -115,8 +116,21 @@ room-member-adapter 把自由房间里空闲的 Codex 成员在 App(codex)
115
116
  room-profiles 导出房间的本机成员软件配置与实际会话 ID(不含凭据),供备份附带;须提供 site、room_id、token。
116
117
  room-restore 把导入房间的本机成员配置登记到本机并核对原会话存在,Claude Code 成员保持手动;须提供 site、room_id、token、local_members。
117
118
  room-fork 把从原房间分支出的新房间登记到本机,各本机成员从原会话分叉、不含分支点之后的执行,原会话不变;须提供 site、room_id、token、source_room_id、source_token、executions({成员名: [排除的执行编号]})。
118
- workflow-adopt 主持人在本房间启用工作流:--draft 把自己的草稿(一段,分段头 1/1)保存进工作流库后启用,--id 启用工作流库里已有的工作流(不改动它);缺的卡片成员由本机服务邀请,项目权限最高可写;须房主已为本房间打开“允许主持人自建工作流”。凭据为本次任务的 access_file(或 DISCUSS_ACCESS_FILE)。
119
+ workflow-adopt 主持人在本房间启用工作流:--draft 把自己的草稿(一段,分段头 1/1)保存进工作流库后启用,--id 启用工作流库里已有的工作流(不改动它);缺的卡片成员由本机服务邀请,项目权限最高可写;须房主已为本房间打开“主持人完整权限”。凭据为本次任务的 access_file(或 DISCUSS_ACCESS_FILE)。
119
120
  workflow-join 主持人为本房间在用工作流里还没有成员的卡片(启用后才新增的)邀请成员,不填 --card 即全部;无需额外授权,离开或移除的成员由用户请回。凭据同上。
121
+ host-control 主持人代房主控制房间,须房主已为本房间打开“主持人完整权限”,凭据同上;成员用 --member(成员 ID 或显示名),不能是主持人自己:
122
+ 诊断:details --execution N 读取该执行的本机过程日志(服务端详情与事件记录用 execution / room events 读取);
123
+ 恢复:stop --member 打断;stop-all 停止全部执行、请求与接力;steer --member --text 话 [--submission-id ID] 向它进行中的执行插话;
124
+ retry --execution N 重试失败的执行;model --member [--model M] [--effort E] 改模型与强度(空值恢复默认);
125
+ adapter --member --adapter ID 在同一软件的 App 与 CLI 间切换;access --member --access read|write 调整项目权限;
126
+ 成员:invite --name 平台 [--access read|write] [--model M] [--effort E] 用本机已配置的软件邀请,项目权限最高可写;
127
+ away / remove / back --member 暂离、移除、请回;label --member [--label 显示名] [--description 描述];
128
+ 撤回:withdraw --message N 撤回自己的一次派活:接力或循环停下,未开始的请求取消,进行中的停止,不再交回;
129
+ 兜底:checkin --minutes N 房间有进行中的工作时每 N 分钟(5–120)给你发一次定时检查,房间空闲、停止全部或换主持人后自动结束;checkin --off 关闭;
130
+ 推进:order 读取发言顺序,order --order 甲,乙 --revision REV 调整尚未发言的部分;
131
+ discussion 读取讨论,discussion --do start --goal 目标 --revision REV 开启(上限用房间默认值),--do pause|resume|end --revision REV;
132
+ goal 读取 Goal,goal --do start --goal 目标 --revision REV 开启(预算用房主默认值,默认不限时为 8 次续跑、90 分钟),--do pause|resume|end --revision REV。
133
+ 不开放:批准超出上限的轮数、项目权限高于可写或放行工具、改项目目录、撤回回退用户消息、私聊、对主持人自己操作。
120
134
  workflow-check 按导入规则校验工作流文件夹或单段草稿,返回预览,不保存;workflow-save 校验后保存进工作流库(同 ID 覆盖),卡片按声明的项目权限保存。写法见 workflow-creator 技能。
121
135
  JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
122
136
  或 room_id(已有房间)、participants(本次软件覆盖配置)。
@@ -605,9 +619,9 @@ function validate_profile(name, profile, binding = false) {
605
619
  }
606
620
 
607
621
  /** Send one request without following redirects that could disclose the room credential. */
608
- async function site_request(site, path, token, body) {
622
+ async function site_request(site, path, token, body, headers = {}) {
609
623
  const response = await fetch(`${site}${path}`, { method: body === undefined ? 'GET' : 'POST',
610
- headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
624
+ headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token, ...headers },
611
625
  body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) });
612
626
  const result = await response.json();
613
627
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
@@ -810,7 +824,7 @@ function manual_access(path, input, name, token) {
810
824
  * The local configuration is written before the website call, so a retry reuses the same credential.
811
825
  * A manual member answers with its own credential and is never woken.
812
826
  */
813
- export async function room_member(home, raw, locked = false) {
827
+ export async function room_member(home, raw, locked = false, headers = {}) {
814
828
  const { input, path } = await member_room_context(home, raw);
815
829
  const { action, name } = input;
816
830
  const access = input.access || 'runner';
@@ -826,12 +840,12 @@ export async function room_member(home, raw, locked = false) {
826
840
  if (action === 'add' && input.new_instance) {
827
841
  // A name once bound keeps its session locally, so a new instance never reuses a removed member's name.
828
842
  const taken = new Set([...roster.map(member => member.name), ...Object.keys(config.participants), ...Object.keys(config.members || {})]);
829
- return room_member(home, { ...raw, name: next_instance(platform_of(name), taken), new_instance: false }, true);
843
+ return room_member(home, { ...raw, name: next_instance(platform_of(name), taken), new_instance: false }, true, headers);
830
844
  }
831
845
  const current = roster.find(member => member.name === name);
832
846
  if (input.member_token !== undefined && (typeof input.member_token !== 'string' || input.member_token.length < 32 || input.member_token.length > 256)) throw new Error('member_token 须为 32–256 字符');
833
847
  if (current?.status === 'active' && input.member_token && config.members?.[name]?.token === input.member_token) {
834
- const view = await site_request(input.site, `/api/rooms/${input.room_id}/members/${current.id}`, input.token, { action: 'active', token: input.member_token });
848
+ const view = await site_request(input.site, `/api/rooms/${input.room_id}/members/${current.id}`, input.token, { action: 'active', token: input.member_token }, headers);
835
849
  return { room_id: input.room_id, member: name, access: current.access, free: view, access_file: null };
836
850
  }
837
851
  if (action === 'add' && !current && config.participants[name] && !locked) {
@@ -842,7 +856,7 @@ export async function room_member(home, raw, locked = false) {
842
856
  && !roster.some(member => member.name === key));
843
857
  const renamed = retried || free_instance(name, taken);
844
858
  const participants = raw.participants?.[name] ? { participants: { ...raw.participants, [renamed]: raw.participants[name] } } : {};
845
- return room_member(home, { ...raw, name: renamed, ...participants }, true);
859
+ return room_member(home, { ...raw, name: renamed, ...participants }, true, headers);
846
860
  }
847
861
  if (action === 'rejoin' && current?.status !== 'away') throw new Error(`${name} 不是离开状态,无需请回`);
848
862
  if (action === 'add' && current) throw new Error(`${name} 已是本房间成员;离开的成员请用请回`);
@@ -866,7 +880,7 @@ export async function room_member(home, raw, locked = false) {
866
880
  ? { name, adapter: manual ? '' : profile.adapter, token, access, session_mode: profile?.session_id ? 'borrowed' : 'new' }
867
881
  : { action: 'active', token };
868
882
  const view = await site_request(input.site, action === 'add' ? `/api/rooms/${input.room_id}/members` : `/api/rooms/${input.room_id}/members/${current.id}`,
869
- input.token, body);
883
+ input.token, body, headers);
870
884
  return { room_id: input.room_id, member: name, access: manual ? 'manual' : 'runner', free: view, access_file };
871
885
  } finally { release(); }
872
886
  }
@@ -1623,7 +1637,7 @@ async function host_room(home, access_file) {
1623
1637
  const room = await api(config, '');
1624
1638
  const roster = room.free?.members || [];
1625
1639
  if (!host || !roster.some(member => member.name === host && member.role === 'host' && member.status === 'active')) throw new Error('只有本房间的主持人能运行此命令');
1626
- return { path, config, roster };
1640
+ return { path, config, roster, labels: room.labels || {}, host };
1627
1641
  }
1628
1642
 
1629
1643
  /**
@@ -1722,6 +1736,154 @@ export async function join_workflow(home, { access_file, cards }) {
1722
1736
  return sync_workflow_cards(home, path, config, roster, cards?.length ? cards : known.filter(card => !mapped.includes(card)));
1723
1737
  }
1724
1738
 
1739
+ // When the owner left the room's goal budget at no limit, a goal the host opens itself still stops by these.
1740
+ const HOST_GOAL_BUDGET = { max_continues: 8, max_minutes: 90 };
1741
+ const LIVE_EXECUTION = ['dispatched', 'started'];
1742
+
1743
+ /** The active-or-away member a host names by member ID or display name; never the host itself. */
1744
+ function host_target({ roster, labels, host }, wanted) {
1745
+ if (!wanted) throw new Error('须用 --member 指定成员(成员 ID 或显示名)');
1746
+ const member = roster.find(item => item.status !== 'removed' && (item.name === wanted || labels[item.name] === wanted));
1747
+ if (!member) throw new Error(`本房间没有成员 ${wanted}`);
1748
+ if (member.name === host) throw new Error('主持人不能对自己执行此操作;更换或移除主持人由用户决定');
1749
+ return member;
1750
+ }
1751
+
1752
+ /** The profile a host-invited member starts from: this computer's saved settings, project access at most write. */
1753
+ function host_invite_profile(home, name, { access, model, effort }) {
1754
+ if (access && !HOST_CARD_ACCESS.includes(access)) throw new Error('主持人邀请的成员项目权限只能是 read 或 write');
1755
+ if (!card_platform_ready(home, name)) {
1756
+ const ready = [...new Set([...Object.keys(launch_settings(home).participants), ...Object.keys(API_PROVIDERS).filter(platform => card_api_profile(home, platform))])];
1757
+ throw new Error(`本机没有配置 ${name} 的软件或 API Key;只能邀请本机已配置的平台:${ready.join('、')}`);
1758
+ }
1759
+ const api = card_api_profile(home, platform_of(name));
1760
+ const saved = launch_settings(home).participants[platform_of(name)]?.project_access;
1761
+ const project_access = access || (HOST_CARD_ACCESS.includes(saved) ? saved : 'write');
1762
+ return { ...api, project_access, ...(model ? { model } : {}), ...(effort && !api ? { effort } : {}) };
1763
+ }
1764
+
1765
+ /** The positive execution number a host passes with --execution. */
1766
+ function execution_number(value) {
1767
+ const id = Number(value);
1768
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error('须用 --execution 指定执行编号');
1769
+ return id;
1770
+ }
1771
+
1772
+ /** Read the discussion, or start (room default limits), pause, resume or end it with the revision just read. */
1773
+ function host_discussion(ctx, { do: step, goal, revision, submission_id }) {
1774
+ if (!step) return ctx.call('/discussion');
1775
+ if (step === 'start') {
1776
+ if (!goal) throw new Error('开启讨论须用 --goal 写明讨论目标');
1777
+ return ctx.call('/discussion', { action: 'start', goal, revision, submission_id: submission_id || randomUUID() });
1778
+ }
1779
+ if (!['pause', 'resume', 'end'].includes(step)) throw new Error('--do 须为 start、pause、resume 或 end');
1780
+ return ctx.call('/discussion', { action: step, revision });
1781
+ }
1782
+
1783
+ /** Read the goal, or open it under the owner's default budget (a bounded one when that is unlimited), pause, resume or end it. */
1784
+ function host_goal(ctx, { do: step, goal, revision }) {
1785
+ if (!step) return ctx.call('/goal');
1786
+ if (step === 'start') {
1787
+ if (!goal) throw new Error('开启 Goal 须用 --goal 写明目标');
1788
+ const preferences = launch_settings(ctx.home).create_preferences || {};
1789
+ const budget = {
1790
+ max_continues: preferences.goal_max_continues || HOST_GOAL_BUDGET.max_continues,
1791
+ max_minutes: preferences.goal_max_minutes || HOST_GOAL_BUDGET.max_minutes,
1792
+ };
1793
+ return ctx.call('/goal', { action: 'start', objective: goal, revision, ...budget });
1794
+ }
1795
+ if (!['pause', 'resume', 'end'].includes(step)) throw new Error('--do 须为 start、pause、resume 或 end');
1796
+ return ctx.call('/goal', { action: step === 'end' ? 'clear' : step, revision });
1797
+ }
1798
+
1799
+ /** Steer a member's running execution with an interjection; a retry passes the same --submission-id. */
1800
+ function host_steer(ctx, { member: wanted, text, submission_id }) {
1801
+ const member = host_target(ctx, wanted);
1802
+ if (!text) throw new Error('须用 --text 写明要对它说的话');
1803
+ if (!LIVE_EXECUTION.includes(member.execution?.status)) throw new Error(`${wanted} 当前没有进行中的执行;要它发言请在回答里用 --to 点名`);
1804
+ return ctx.call(`/executions/${member.execution.id}`, { action: 'interject', text, submission_id: submission_id || randomUUID() });
1805
+ }
1806
+
1807
+ /** Set a member's display name and, optionally, its description. */
1808
+ async function host_label(ctx, { member: wanted, label, description }) {
1809
+ const member = host_target(ctx, wanted);
1810
+ if (!label && !description) throw new Error('须用 --label 或 --description 指定显示名或描述');
1811
+ if (label) await ctx.call('/labels', { labels: { [member.name]: label } });
1812
+ if (description) await ctx.call('/descriptions', { descriptions: { [member.name]: description } });
1813
+ return { member: member.name, label: label || null, description: description || null };
1814
+ }
1815
+
1816
+ /** Invite a member from this computer's configured software, project access at most write. */
1817
+ async function host_invite(ctx, options) {
1818
+ if (!options.name) throw new Error('须用 --name 指定要邀请的平台(如 Claude、Codex)');
1819
+ const taken = new Set(ctx.roster.map(member => member.name));
1820
+ const name = taken.has(options.name) ? free_instance(options.name, taken) : options.name;
1821
+ const profile = host_invite_profile(ctx.home, name, options);
1822
+ const joined = await room_member(ctx.home, { ...ctx.owner, action: 'add', name, participants: { [name]: profile } }, false, ctx.headers);
1823
+ return { member: joined.member, project_access: profile.project_access };
1824
+ }
1825
+
1826
+ /** Read the speaking order, or change its not-yet-spoken part with the revision just read; display names are accepted. */
1827
+ function host_order(ctx, { order, revision }) {
1828
+ if (!order) return ctx.call('/speaking-order');
1829
+ if (!revision) throw new Error('调整发言顺序前先不带 --order 读取当前顺序,再用 --revision 带上其 revision');
1830
+ const names = order.split(',').map(name => name.trim()).filter(Boolean)
1831
+ .map(name => ctx.roster.find(member => member.status === 'active' && ctx.labels[member.name] === name && member.name !== name)?.name || name);
1832
+ return ctx.call('/speaking-order', { revision, order: names });
1833
+ }
1834
+
1835
+ const HOST_CONTROLS = {
1836
+ stop: (ctx, o) => ctx.call(`/members/${host_target(ctx, o.member).id}/interrupt`, {}),
1837
+ 'stop-all': ctx => ctx.call('/stop', {}),
1838
+ steer: host_steer,
1839
+ retry: (ctx, o) => ctx.call(`/executions/${execution_number(o.execution)}`, { action: 'retry' }),
1840
+ details: (ctx, o) => execution_details_command(ctx.home, { ...ctx.owner, execution_id: execution_number(o.execution) }),
1841
+ order: host_order,
1842
+ invite: host_invite,
1843
+ away: (ctx, o) => ctx.call(`/members/${host_target(ctx, o.member).id}`, { action: 'away' }),
1844
+ remove: (ctx, o) => ctx.call(`/members/${host_target(ctx, o.member).id}`, { action: 'remove' }),
1845
+ back: (ctx, o) => room_member(ctx.home, { ...ctx.owner, action: 'rejoin', name: host_target(ctx, o.member).name }, false, ctx.headers),
1846
+ model: (ctx, o) => room_member_model(ctx.home, { ...ctx.owner, name: host_target(ctx, o.member).name, model: o.model || '', effort: o.effort || '' }),
1847
+ access: (ctx, o) => {
1848
+ if (!HOST_CARD_ACCESS.includes(o.access)) throw new Error('主持人只能把成员的项目权限设为 read 或 write');
1849
+ return room_member_access(ctx.home, { ...ctx.owner, name: host_target(ctx, o.member).name, project_access: o.access });
1850
+ },
1851
+ adapter: (ctx, o) => room_member_adapter(ctx.home, { ...ctx.owner, name: host_target(ctx, o.member).name, adapter: o.adapter }),
1852
+ label: host_label,
1853
+ discussion: host_discussion,
1854
+ goal: host_goal,
1855
+ withdraw: (ctx, o) => {
1856
+ const id = Number(o.message);
1857
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error('须用 --message 指定要撤回的派活消息编号');
1858
+ return ctx.call(`/dispatches/${id}/withdraw`, {});
1859
+ },
1860
+ checkin: (ctx, o) => {
1861
+ if (o.off) return ctx.call('/host-checkin', { off: true });
1862
+ const minutes = Number(o.minutes);
1863
+ if (!Number.isSafeInteger(minutes)) throw new Error('须用 --minutes 指定间隔分钟数(5–120),或 --off 关闭');
1864
+ return ctx.call('/host-checkin', { minutes });
1865
+ },
1866
+ };
1867
+
1868
+ /**
1869
+ * The host runs a room control the owner otherwise does in the web page, when the owner granted this room full host
1870
+ * control (`host_workflows`), so it can keep work moving and handle what goes wrong by itself: stop, steer or retry a
1871
+ * member, read an execution's local log, change the speaking order, invite (configured software, at most write), send
1872
+ * away, remove or bring back members, change a member's model, effort, client or access (read/write only), label it,
1873
+ * and read, start, pause, resume or end the discussion or goal. The site checks every write as for the owner (revisions
1874
+ * included). What stays the owner's: acting on the host itself, approving rounds beyond the limit, access above write
1875
+ * or tool approvals, project directories, the owner's messages and private chats.
1876
+ */
1877
+ export async function host_control(home, action, options) {
1878
+ if (!Object.hasOwn(HOST_CONTROLS, action || '')) throw new Error(`操作须为 ${Object.keys(HOST_CONTROLS).join('、')} 之一`);
1879
+ const { config, roster, labels, host } = await host_room(home, options.access_file);
1880
+ if (!config.host_workflows) throw new Error('房主没有为本房间打开“主持人完整权限”;请让用户在电脑上“成员与设置 → 协作设置”打开后再试');
1881
+ const headers = { 'User-Agent': `AI-CoDiscuss-host:${host}` };
1882
+ const owner = { site: config.site, room_id: config.room, token: config.token };
1883
+ const call = (path, body) => site_request(config.site, `/api/rooms/${config.room}${path}`, config.token, body, headers);
1884
+ return HOST_CONTROLS[action]({ home, config, roster, labels, host, headers, owner, call }, options);
1885
+ }
1886
+
1725
1887
  /** Cards as the host may bring them: any access above writing is lowered to it. */
1726
1888
  const host_cards = agents => agents.map(agent => agent.project_access && !HOST_CARD_ACCESS.includes(agent.project_access)
1727
1889
  ? { ...agent, project_access: 'write' } : agent);
@@ -1736,7 +1898,7 @@ const host_cards = agents => agents.map(agent => agent.project_access && !HOST_C
1736
1898
  export async function adopt_workflow(home, { access_file, draft_file, workflow_id }) {
1737
1899
  if (!draft_file === !workflow_id) throw new Error('须提供 --draft 草稿文件或 --id 工作流库中的工作流 ID 之一;凭据用 --access-file 或 DISCUSS_ACCESS_FILE');
1738
1900
  const { config, roster } = await host_room(home, access_file);
1739
- if (!config.host_workflows) throw new Error('房主没有允许本房间的主持人自建工作流;请让用户在电脑上“成员与设置 → 协作设置”打开后再试');
1901
+ if (!config.host_workflows) throw new Error('房主没有为本房间打开“主持人完整权限”;请让用户在电脑上“成员与设置 → 协作设置”打开后再试');
1740
1902
  const bound = config.workflow?.id;
1741
1903
  let workflow;
1742
1904
  if (workflow_id) workflow = get_workflow(home, workflow_id);
@@ -1795,6 +1957,10 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1795
1957
  file: { type: 'string' }, 'dry-run': { type: 'boolean' }, browser: { type: 'boolean' }, remote: { type: 'boolean' }, device: { type: 'string' },
1796
1958
  'configuration-locked': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
1797
1959
  draft: { type: 'string' }, 'access-file': { type: 'string' }, dir: { type: 'string' }, card: { type: 'string' }, id: { type: 'string' },
1960
+ member: { type: 'string' }, order: { type: 'string' }, revision: { type: 'string' }, name: { type: 'string' }, access: { type: 'string' },
1961
+ model: { type: 'string' }, effort: { type: 'string' }, do: { type: 'string' }, execution: { type: 'string' }, text: { type: 'string' },
1962
+ goal: { type: 'string' }, label: { type: 'string' }, description: { type: 'string' }, adapter: { type: 'string' }, 'submission-id': { type: 'string' },
1963
+ message: { type: 'string' }, minutes: { type: 'string' }, off: { type: 'boolean' },
1798
1964
  } });
1799
1965
  if (values.help || !positionals.length) return process.stdout.write(HELP);
1800
1966
  if (positionals[0] === 'workflow-adopt') {
@@ -1802,6 +1968,13 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1802
1968
  const result = await adopt_workflow(home, { draft_file: values.draft, workflow_id: values.id, access_file: values['access-file'] || process.env.DISCUSS_ACCESS_FILE });
1803
1969
  return process.stdout.write(`${JSON.stringify(result)}\n`);
1804
1970
  }
1971
+ if (positionals[0] === 'host-control') {
1972
+ const { member, order, revision, name, access, model, effort, execution, text, goal, label, description, adapter } = values;
1973
+ const result = await host_control(service_home(), positionals[1], { member, order, revision, name, access, model, effort, execution, text, goal,
1974
+ label, description, adapter, message: values.message, minutes: values.minutes, off: values.off, do: values.do, submission_id: values['submission-id'], access_file: values['access-file'] || process.env.DISCUSS_ACCESS_FILE });
1975
+ return process.stdout.write(`${JSON.stringify(result)}
1976
+ `);
1977
+ }
1805
1978
  if (positionals[0] === 'workflow-join') {
1806
1979
  const cards = (values.card || '').split(',').map(card => card.trim()).filter(Boolean);
1807
1980
  const result = await join_workflow(service_home(), { cards, access_file: values['access-file'] || process.env.DISCUSS_ACCESS_FILE });
@@ -1818,7 +1991,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1818
1991
  // Another device's request may only use what this computer granted; the flag is set by the relay, never by the request.
1819
1992
  if (values.remote) {
1820
1993
  guard_remote_request(positionals[0], input, { projects: launch_projects(home).projects, participants: launch_settings(home).participants, home });
1821
- if (positionals[0] === 'room-workflow' && input?.action === 'host') throw new Error('“允许主持人自建工作流”只能在电脑上开启');
1994
+ if (positionals[0] === 'room-workflow' && input?.action === 'host') throw new Error('“主持人完整权限”只能在电脑上开启');
1822
1995
  // Another device repeats the saved program locations it cannot see; keep them rather than dropping them.
1823
1996
  if (positionals[0] === 'settings' && input?.participants) input.participants = keep_saved_locations(home, input.participants);
1824
1997
  // Tool approvals saved here stay this computer's decision; a room created from another device does not take them.
@@ -74,9 +74,9 @@ export function access_label(level) { return level === 'own' ? '自行决定' :
74
74
 
75
75
  /**
76
76
  * Subcommands an automatic task may run; service, run, api, setup and the rest of launch stay unapproved.
77
- * `launch workflow-adopt` and `launch workflow-join` check themselves that the caller is the room's host (adopt also needs the owner's grant).
77
+ * `launch workflow-adopt` and `launch workflow-join` and `launch host-control` check themselves that the caller is the room's host (adopt and host-control also need the owner's grant).
78
78
  */
79
- export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'goal', 'mode', 'review', 'sequence', 'execution', 'health', 'schema', 'launch workflow-adopt', 'launch workflow-join'];
79
+ export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'goal', 'mode', 'review', 'sequence', 'execution', 'health', 'schema', 'launch workflow-adopt', 'launch workflow-join', 'launch host-control'];
80
80
 
81
81
  /**
82
82
  * Whether a member may run commands beyond the codiscuss subcommands: every level but 只读, and 可写 only with a project
package/web/app.js CHANGED
@@ -3891,6 +3891,13 @@ function stop_summary(executions) {
3891
3891
  }
3892
3892
 
3893
3893
  /** Stop the whole free room: running work and everything still owed end; a new message starts again. */
3894
+ /** The owner turns off the host's timed check-in shown on the free board. */
3895
+ async function stop_host_checkin() {
3896
+ const room_id = state.room.id;
3897
+ await request_json(`/api/rooms/${room_id}/host-checkin`, { off: true });
3898
+ if (state.room?.id === room_id) await refresh_room();
3899
+ }
3900
+
3894
3901
  async function stop_room() {
3895
3902
  const room_id = state.room.id;
3896
3903
  const live = (state.room.free?.executions || []).filter(item => ["dispatched", "started"].includes(item.status));
@@ -4100,8 +4107,9 @@ async function copy_fork_files(source_id, room_id, token, mapping) {
4100
4107
  */
4101
4108
  async function fork_room(message_id) {
4102
4109
  const source = state.room;
4103
- const source_token = localStorage.getItem(`discuss-access:${source.id}`);
4104
- if (!source_token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
4110
+ if (!has_room_control(source.id)) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器或登录管理员");
4111
+ // Without the room credential the local service or the relay acts with this browser's administrator authority.
4112
+ const source_token = localStorage.getItem(`discuss-access:${source.id}`) || "";
4105
4113
  const snapshot = await request_json(`/api/rooms/${encodeURIComponent(source.id)}/backup`);
4106
4114
  const room_id = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
4107
4115
  const { body, executions } = fork_snapshot(snapshot, message_id, room_id);
@@ -4954,7 +4962,7 @@ async function execute_view_action(name, value) {
4954
4962
  copy_cli: () => copy_context(), copy_background,
4955
4963
  download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_discussion, restart_discussion, start_room_discussion, end_room_discussion, start_room_goal, update_room_goal, goal_control,
4956
4964
  save_launch_defaults, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
4957
- load_room_configuration, save_room_configuration, interrupt_member, retry_delivery, retract_message, retract_latest, stop_room, rewind_and_send, fork_room, change_member, reorder_speakers, invite_member,
4965
+ load_room_configuration, save_room_configuration, interrupt_member, retry_delivery, retract_message, retract_latest, stop_room, stop_host_checkin, rewind_and_send, fork_room, change_member, reorder_speakers, invite_member,
4958
4966
  enable_room_workflow, move_workflow_members, sync_room_workflow, set_room_host_workflows,
4959
4967
  adopt_recommendation, rate_decision,
4960
4968
  queue_send: id => queue_request({ action: "send", id }), queue_delete: delete_queued,