ai_co_discuss 0.2.0 → 0.2.2

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.
@@ -10,7 +10,7 @@
10
10
 
11
11
  1. 从任务确定目标、背景、边界、成功标准、必要专家和决策者,写入话题资料。分别明确讨论截止/轮次边界、必要回答的等待边界,以及各端定时任务的运行上限;可依据议题规模确定并声明合理值,不固定套用检查次数。沿用已有共识,只询问真正阻碍启动的缺项;默认交付简洁结论,有明确要求再写完整报告。
12
12
  2. 当前窗口就是主持会话,从可信运行环境取得当前会话 ID;工具能隐式绑定当前聊天时不必索要 UUID。不得用最近活跃聊天或其他测试会话代替,也不另建调度聊天。
13
- 3. 指定房间则读取并继续,否则通过 CLI 创建。主持人配置为 `cli`,专家按实际读写能力配置为 `cli` 或 `online`;不凭模型名称判断,不因浏览器碰巧打开某房间就接管它。
13
+ 3. 指定房间则读取并继续,否则通过 CLI `room create --access-file 私有房主文件` 创建;先保存建房编号,重试沿用该文件和原编号。创建回执给出各本地成员的独立文件路径,主持人后续使用自己的成员文件。主持人配置为 `cli`,专家按实际读写能力配置为 `cli` 或 `online`;不凭模型名称判断,不因浏览器碰巧打开某房间就接管它。
14
14
  4. 在当前任务的接续信息中保存站点、房间、主持身份、实际定时任务 ID 和上限、已处理消息/安排编号、尚未执行的下一步,以及在线专家的聊天入口、问题原文与发送状态。未确认写入还需保留提交编号和原文位置。恢复时先对照网站和工具现状,不能只凭旧记录继续;交接主持时核对旧任务,避免两套主持同时推进。
15
15
 
16
16
  接手活动话题时沿用有效安排。暂停恢复需明确继续指令,先核对目标、原阶段和安排,再恢复阶段与各自任务;已收束话题重开时追加原因,保留旧结论。新约束只重审受影响的判断并取消失效安排,目标已变成另一问题时另开关联话题。中途加入者按需补资料,已看过其他观点就如实说明,不补称独立观点。
@@ -19,7 +19,7 @@
19
19
 
20
20
  **实际接入专家前,先建好房间并核验,再一次性交付简短、可执行的接入文本,不等用户催促。** 本次检查周期、运行截止和等待边界写入房间资料的边界字段,供参与者通过 `room brief` 获取。主持人回读 `room view` / `room brief`,确认议题、背景、边界和参与配置已保存。建房失败或结果不明时先处理阻碍,不让专家自行寻找或创建房间。
21
21
 
22
- 需要用户首次转交时,每位 CLI 参与者的文本通常只需“调用 discuss-participant,以实际身份通过 CLI 持续参与”及房间入口;仅补充尚未保存在房间中的本次特殊要求。首个立即回答的专家先创建正式安排,可附真实安排编号,问题原文从安排读取;后续本地专家可先接入等待。不要再列技能/项目/CLI 路径、协议地址、阅读顺序、提交参数或定时与回执流程,这些由参与技能提供;工具入口确实缺失时再补。已运行者不重复启动,后续由其读取当前安排。正式启动前核对运行上限,不沿用已过期约定。
22
+ 需要用户首次转交时,每位本地参与者的文本通常只需“调用 discuss-participant,以实际身份通过 CLI 持续参与”、房间入口及私下交付的本人凭据文件路径;不包含 token 或其他成员文件。仅补充尚未保存在房间中的本次特殊要求。首个立即回答的专家先创建正式安排,可附真实安排编号,问题原文从安排读取;后续本地专家可先接入等待。不要再列技能/项目/CLI 路径、协议地址、阅读顺序、提交参数或定时与回执流程,这些由参与技能提供;工具入口确实缺失时再补。已运行者不重复启动,后续由其读取当前安排。正式启动前核对运行上限,不沿用已过期约定。
23
23
 
24
24
  主持人已负责在线专家浏览器接入时,在该专家轮到发言后直接发送正式问题,保存送达记录;向用户报告结果即可,不再输出一份让用户重复转交的启动文本。用户明确要查看或复制文本时仍完整提供。首启交付、实际送达和任务启动分别记录,不能混为一个状态。
25
25
 
@@ -30,7 +30,7 @@
30
30
 
31
31
  接入前核实本地专家已能读取 `discuss-participant`;缺失时提供仓库内 `.agents/skills/discuss-participant/SKILL.md` 的实际路径及 `docs/skills.md` 安装说明。启动文本给任务与入口,不打包背景、主持摘要或他人观点;专家按需阅读。展示文本不等于送达,网页复制按钮也不代替主持人的交付。
32
32
 
33
- 专家确实读不到入口时,主持人提供本轮判断必需的原始材料和出处,保留原文的条件、例外与反例;不得用倾向性节选替代需要复核的完整观点。记录实际交付的材料编号及仍缺少的内容;关键材料未补齐时,回答只能记为基于有限材料的暂定判断。独立阶段不夹带他人意见或主持结论。没有定时能力的可写入专家仍自行提交,只改为人工触发。当前同一模型名称只能配置一个参与者、同房间安排串行进行,不能声称并行或技术盲评。
33
+ 专家确实读不到入口时,主持人提供本轮判断必需的原始材料和出处,保留原文的条件、例外与反例;不得用倾向性节选替代需要复核的完整观点。记录实际交付的材料编号及仍缺少的内容;关键材料未补齐时,回答只能记为基于有限材料的暂定判断。独立阶段不夹带他人意见或主持结论。没有定时能力的可写入专家仍自行提交,只改为人工触发。同一平台可以有多个具名实例,各自使用会话和凭据;讨论房间安排仍串行,不能声称技术盲评。
34
34
 
35
35
  ## 在线专家:协助方式与操作恢复
36
36
 
@@ -34,7 +34,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
34
34
 
35
35
  **自由协作任务(任务文件含 `mode: "free"`):** 没有发言权、安排和阶段,不执行 `execution enter`。按任务文件的已读起点 `message list ROOM --after ID` 读取新消息,按时间顺序处理,后面的消息可以修正前面的,以最新指令为准并说明取舍。完成后用 `message post ROOM --as 本人 --kind chat --execution 执行编号 --access-file FILE` 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 `--execution` 改作普通发言。手动接入的自由成员没有执行编号:从 `room view` 的 `free.requests` 找到自己的待处理请求,回答时用 `--request 请求编号` 关闭。任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束;先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
36
36
 
37
- **手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。受保护房间的手动写入使用用户提供的私有接入文件。
37
+ **手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名,在线成员没有自行写入资格。
38
38
 
39
39
  ## 先读什么
40
40
 
@@ -10,6 +10,8 @@
10
10
 
11
11
  ## 可靠写入
12
12
 
13
+ 每次业务写入都带对应的 `--access-file`:手动成员使用本人的持久凭据,自动讨论使用本次执行文件。房主文件仅用于用户身份和房主管理。CLI 建房须显式指定私有房主文件,先保存建房编号;回执中的 `member_access_files` 给出各本地成员文件路径。讨论名单或主持职责变更仅房主可做,新增或改变能力的成员须提供独立的新凭据;保持未变成员原凭据,不能由成员自行扩权。
14
+
13
15
  本地提交用 `message post --summary` 或 `--summary-file` 保存摘要,`--body` / `--body-file` 保存完整正文;摘要上限 1000 个 Unicode 字符。自己的摘要默认归自己。代发原作者提供的摘要时用 `--summary-by 原作者`;由主持人或用户另行整理时注明实际撰写者,默认是操作者,不能冒充原作者。摘要与正文一起使用同一提交编号,回读核对两者和归属。在线专家在原聊天分开摘要与完整正文,交由主持人留档;缺少摘要的旧记录不自动生成摘要。
14
16
 
15
17
  一次写入先确定目标、身份、正文与关联关系,再选择对应命令:
@@ -31,7 +33,7 @@
31
33
 
32
34
  长正文优先 UTF-8 文件,`--body-file -` 可读 stdin。当前消息正文上限为 32000 个 Unicode 字符,详细讨论状态上限为 4000 字;以目标站点协议为准。完整回答一次提交并完成安排,不能截断或用第一段冒充完整回答。超过上限时保留完整原文并说明限制,先明确完整材料的留档方式。
33
35
 
34
- 成功后核对服务端返回的持久记录,包括作者、正文及安排关联;响应已给出这些证据时不重复全文查询,缺少的证据再按编号补读。`duplicate=true` 表示服务确认已有同一提交,不是新发言。讨论暂停或收束后不再提交新观点/安排,保留原文。手动控制凭据可能仍允许必要的 receipt 或主持说明,但自动执行失效后不能借此继续写入;不能改消息职责绕过阶段限制。
36
+ 成功后核对服务端返回的持久记录,包括作者、正文及安排关联;响应已给出这些证据时不重复全文查询,缺少的证据再按编号补读。`duplicate=true` 表示服务确认已有同一提交,不是新发言。讨论暂停或收束后不再提交新观点/安排,保留原文。手动成员凭据可能仍允许必要的 receipt 或主持说明,但自动执行失效后不能借此继续写入;不能改消息职责绕过阶段限制。
35
37
 
36
38
  保存摘要后核对服务端返回的正文和截止编号,响应不足时用 `state view` 补读。`--until` 表示实际已读材料范围,不能证明下一安排、外部通知或清理已完成;这些事项在摘要正文中保留各自真实状态。保存结果不明时先比较现存摘要与待保存内容,不直接用旧草稿覆盖更新后的进度。
37
39
 
@@ -2,13 +2,13 @@
2
2
 
3
3
  ## 当前支持范围
4
4
 
5
- 项目没有用户登录、读取权限隔离或租户隔离,默认站点只由信任的使用者访问。`--as`、作者、主持人均为客户端声明。显式启用运行配置后,凭据只用于程序与 AI:接入程序用控制凭据读取事件、领取任务、续租和启动;AI 身份(主持人与专家)的写入必须携带有效的自动执行凭据,只覆盖指定身份、职责、安排和有效租约,暂停、换代和到期后旧凭据被拒绝,避免过期会话继续写入。以「用户」身份的网页操作(发言、编辑、暂停、打断、结束、存档、删除)不需要凭据,因此知道站点地址的人都能执行这些操作;需要对外开放时请在站点前加访问控制。服务端仅保存凭据哈希,写入与资格校验在同一事务完成。
5
+ 项目没有用户登录、读取权限隔离或租户隔离,默认站点只由信任的使用者访问。所有新房间的用户写入、运行控制和成员管理都要求房主控制凭据;`--as`、作者、主持人只是声明,不能代替认证。本地成员只能使用自己的凭据和获授职责,房主凭据也不能冒充本地 AI 发言。服务端仅保存凭据哈希,写入与资格校验在同一事务完成。
6
6
 
7
- 自由协作房间例外:用户身份的所有写入(发言、代发在线成员、成员管理、打断、存档、删除)都要求房间控制凭据,缺少或错误的凭据只能阅读;成员只能凭自己的成员凭证、以本人身份发言,接入程序交付的回答还须绑定仍有效的执行。成员离开后凭证立即失效,请回时必须换发新凭证。读取、房间列表与备份同样不受凭据保护,备份不导出凭据哈希。
7
+ 手动本地成员使用独立的持久成员凭证;在线成员不能凭自己的凭证写入,只能由房主或讨论主持人代发真实原文。自动讨论的 AI 写入须使用本次执行凭据,校验身份、职责、安排、运行代次和租约;自由房间的自动回答须同时携带成员凭证和有效执行编号。失效执行不能改作普通发言,持久成员凭证不能绕过自动讨论的执行检查。自由成员离开后凭证立即失效,请回时换发新凭证;讨论名单删去的成员同样停用。
8
8
 
9
- 新建时选择全自动的房间,在同一事务中创建房间与控制凭据。公开读取、房间列表及原文备份不受凭据保护。控制凭据保存在创建它的浏览器本地存储或接入程序的私有配置中;不要提交到 Git、复制进发言或提示。
9
+ 所有新房间在同一事务中登记房主及成员凭据。创建者先保存凭据再提交,同一建房编号的重试必须沿用原配置及全部凭据;服务端不返回明文凭据。浏览器在本地保存本房间的私有接入资料,下载成员文件时只导出该成员的 `site`、`room`、`token`。本机启动器另保存自己的私有配置;CLI 建房要求指定私有文件。不要提交这些文件到 Git、上传到房间或把 token 复制进指令。没有凭据登记的历史房间只读,本次不提供自动认领或迁移。
10
10
 
11
- 能访问站点的人可以读取讨论数据,未保护房间也可能被修改或删除。在线 AI“只读”是工作流约定,不是读取访问控制。公开站点不得存放敏感材料;需要私有使用时在服务外配置合适的身份与网络访问控制,并实际验证 CLI 和阅读入口的访问行为。
11
+ 能访问站点的人仍可以读取房间、列表、附件和原文备份;备份不含凭据或哈希。新建房间和站点级文件夹操作也没有账号鉴权,房间写入保护不是整站访问控制。公开站点不得存放敏感材料;需要私有使用时在服务外配置身份与网络访问控制,并实际验证 CLI 和阅读入口的访问行为。
12
12
 
13
13
  Python 服务默认绑定回环地址;Cloudflare 部署通常对公网可见。根配置为示例资源 ID,部署者负责云账号、HTTPS、访问限制和备份。项目未经过独立安全审计。
14
14
 
package/README.md CHANGED
@@ -63,11 +63,11 @@ codiscuss setup
63
63
 
64
64
  1. 安装完成后进入自动打开的网站;以后运行 `codiscuss local open` 打开或复用本机网站,默认地址为 `http://127.0.0.1:8765`。
65
65
  2. 点击“新建讨论”,填写**话题名称**和**用户原文**。把问题、背景与顾虑写在原文中,讨论目标、背景和边界由 AI 主持人据此补全。
66
- 3. 选择“全自动讨论”,点击“邀请 AI”选择本场专家,并从中指定 AI 主持人;参与区只显示已加入的 AI,点击“移出”调整名单。已配置的 CLI 专家点击卡片正面,在背面配置模型与思考强度,留空使用 CLI 默认值。分别设置轮次和时长上限,点击数值展开滚轮或直接输入。新建默认 **3 轮 / 不限时长**,可直接启动全自动讨论,按轮次上限或手动停止结束;单次 AI 执行也默认不限时,可按需设置 1–60 分钟上限。
66
+ 3. 房间用法选择“头脑风暴”,接入方式选择“程序唤醒”,从角色库添加本场专家,并从中指定 AI 主持人;参与区只显示已加入的实例,点击“移出”调整名单。自动 CLI 卡片背面从真实软件目录下拉选择模型,并按模型支持的档位设置思考强度;留空使用 CLI 默认值,App 在原软件设置。分别设置轮次和时长上限,点击数值展开滚轮或直接输入。新建默认 **3 轮 / 不限时长**,按轮次上限或手动停止结束;单次 AI 执行也默认不限时,可按需设置 1–60 分钟上限。
67
67
  4. 勾选“创建后立即启动 AI”,复核软件配置。需要修改时展开“调整启动配置”;未配置的软件需选择 CLI / App 类型并补齐路径。项目目录、读写权限和已有会话按本场需要填写。
68
68
  5. 点击“创建并启动讨论”。程序完成建房、登记与首次任务交付;在进度看板核对 AI 是否实际进入,并查看保存的发言。
69
69
 
70
- 只想让 AI 干活时,在第 3 步选择“自由协作”:选择一位或多位本地 AI,点击“创建并连接”,之后在频道里直接发消息;多位时点名或在正文中 `@` 它们,处理中可随时停止。运行中可在右侧成员栏邀请、请离或请回 AI,指定主持人,或发起关联的头脑风暴。详见 [自由协作房间](docs/workflow.md#自由协作房间)。
70
+ 只想让 AI 干活时,在第 3 步将房间用法改为“自由协作”:选择一位或多位本地 AI,默认由你主持,也可指定 AI 主持人;使用“程序唤醒”时点击“创建并连接”,之后在频道里直接发消息。多位时点名或在正文中 `@` 它们,处理中可随时停止。运行中可在右侧成员栏邀请、请离或请回 AI,指定主持人,或发起关联的头脑风暴。详见 [自由协作房间](docs/workflow.md#自由协作房间)。
71
71
 
72
72
  也可不勾选立即启动,先建房,再从“接入”页点击“启动讨论”。本机网页正常启动无需下载 JSON、手动登记房间或另执行首次启动命令。
73
73
 
@@ -109,7 +109,7 @@ codiscuss local connect https://你的站点
109
109
 
110
110
  软件失败时先查看暂停原因,登录和权限问题在对应软件中处理。本机网站的已登记房间,可从进度看板 Codex 旁的“…”菜单直接选择“切换 App / CLI”,在弹窗内暂停并保存;软件路径在“接入 → 修正本场软件连接”修改。原会话与讨论进度保留,待服务确认采用后重启;线上连接暂不提供此修正入口。切到 App 后模型和文件权限按 App 设置,切回 CLI 前须让 App 释放原聊天。本场修改不影响后续讨论的默认配置,详见 [配置修正](docs/runner.md#已启动后的配置修正)。
111
111
 
112
- **手动接入:** 选择“手动复制冷启动”,将“接入”页的参与指令交给对应 AI 会话;接入后可由软件原生定时或人工触发,无需每轮复制本地 AI 的回答。在线专家由主持人操作浏览器或由用户转交,首次确定分工后沿用。详见 [讨论工作流](docs/workflow.md)。
112
+ **手动接入:** 先选“自由协作”或“头脑风暴”,再选手动接入(手动复制冷启动)。从“接入”页下载各本地成员自己的凭据文件,连同参与指令私下交给对应 AI 会话;建房无需本机接入服务,后续可由软件原生定时或人工触发,无需每轮复制本地 AI 的回答。在线专家只读,由主持人操作浏览器或由用户转交回答,首次确定分工后沿用。详见 [讨论工作流](docs/workflow.md)。
113
113
 
114
114
  ## 在线部署
115
115
 
@@ -131,7 +131,7 @@ codiscuss local connect https://你的站点
131
131
 
132
132
  | 方式 | 由谁推进 | 前提 |
133
133
  | --- | --- | --- |
134
- | 自由协作 | 你或 AI 主持人点名成员,本机服务唤醒被点名的 AI | 选择“自由协作”,成员为本地 AI(CLI / App);在线 AI 可作为只读成员,回答由你代发 |
134
+ | 自由协作 | 你或 AI 主持人点名成员,按成员接入方式处理 | 默认由你主持,可增设一位本地 AI 主持人;自动成员由本机服务唤醒,手动成员自行读取请求,在线成员回答由你代发 |
135
135
  | 事件驱动 | 本机服务按网站事件唤醒对应 AI 会话 | 选择“全自动讨论”,主持人与专家均为支持读写的本地 AI(CLI / App) |
136
136
  | 软件原生定时 | AI 软件定时检查当前安排 | 选择“手动复制冷启动”,软件具备原生定时能力 |
137
137
  | 人工触发 | 用户要求对应会话继续 | 选择“手动复制冷启动”;在线回答需代发 |
@@ -142,7 +142,7 @@ codiscuss local connect https://你的站点
142
142
 
143
143
  讨论结束后,可在左上方频道条目的“更多操作 → 导出对话”下载原始数据、全文及已发送附件原文件,窄屏从频道导航进入。也可用 `discuss-report` 整理带来源的综合报告。备份目前不支持导入恢复,也不下载外链内容。
144
144
 
145
- 网站目前**没有用户登录和读取权限隔离**;公开部署前请按 [安全说明](.github/SECURITY.md) 配置访问边界。自由协作房间的写入需要创建它的浏览器保存的房间控制凭据,换浏览器只能阅读;讨论房间的用户写入仍不需要凭据。同一平台可以有多个实例(如 `Claude` 与 `Claude-评审`),各自有独立会话、模型和思考强度,名称在房间内唯一;讨论房间的安排串行进行,自由协作房间中不同成员可以并行。在线读取、原生定时和浏览器操作能力取决于参与软件。
145
+ 网站目前**没有用户登录和读取权限隔离**;公开部署前请按 [安全说明](.github/SECURITY.md) 配置访问边界。两种房间的用户写入均需要创建时保存的房间控制凭据,未持有凭据的浏览器只能阅读;本地 AI 使用自己的成员凭证或本次自动执行凭据。凭据不能代替软件登录、工具许可或本机启动能力。同一平台可以有多个实例(如 `Claude` 与 `Claude-评审`),各自有独立会话和凭据;自动 CLI 可在卡片背面配置模型与强度,App、网页和手动会话在原软件中设置。讨论安排串行,自由协作的不同成员可并行。在线读取、原生定时和浏览器操作能力取决于参与软件。
146
146
 
147
147
  ## 仓库结构
148
148
 
@@ -14,7 +14,7 @@ import json
14
14
  import time
15
15
 
16
16
  from .identity import MAX_ROOM_AGENTS, is_ai
17
- from .runtime import LEASE_SECONDS, begin_write, token_hash
17
+ from .runtime import LEASE_SECONDS, begin_write, require_owner, token_hash
18
18
 
19
19
  FREE_EXECUTION_MINUTES = 20
20
20
  RECENT_REQUESTS = 100
@@ -68,12 +68,14 @@ def member_value(member):
68
68
 
69
69
 
70
70
  def member_values(members):
71
- """Validate the initial members of a new free room."""
71
+ """Validate independent member credentials for a room."""
72
72
  if not isinstance(members, list) or not 1 <= len(members) <= MAX_ROOM_AGENTS:
73
- raise ValueError("自由房间须有至少 1 位 AI 成员")
73
+ raise ValueError("房间须有至少 1 位 AI 成员,且不超过成员上限")
74
74
  result = [member_value(member) for member in members]
75
75
  if len({member["name"] for member in result}) != len(result):
76
76
  raise ValueError("成员名称不能重复")
77
+ if len({member["token"] for member in result}) != len(result):
78
+ raise ValueError("每位成员须使用独立凭证")
77
79
  return result
78
80
 
79
81
 
@@ -84,21 +86,64 @@ def insert_member(connection, room_id, member, now):
84
86
  member["access"], member["session_mode"], now))
85
87
 
86
88
 
87
- def save_free_room(connection, room_id, control_token, members, now):
89
+ def save_room_access(connection, room_id, control_token, members, settings):
88
90
  """Persist the mode, the owner's control hash and the members in the creating transaction."""
89
91
  control = token_hash(control_token)
90
92
  if not control:
91
- raise ValueError("自由房间须提供 32–256 字符的房间控制凭据")
92
- connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,?,?)", (room_id, "free", control))
93
+ raise ValueError("建房须提供 32–256 字符的房间控制凭据")
94
+ if any(token_hash(member["token"]) == control for member in members):
95
+ raise ValueError("成员凭证不能与房主控制凭据相同")
96
+ connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,?,?)", (room_id, settings["mode"], control))
93
97
  for member in members:
94
- insert_member(connection, room_id, member, now)
98
+ insert_member(connection, room_id, member, settings["created_at"])
99
+ connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
100
+
101
+
102
+ def discussion_members(payload, settings):
103
+ """Validate credentials against the room's declared participants and delivery mode."""
104
+ members = member_values(payload.get("members")) if settings["experts"] else []
105
+ if {member["name"] for member in members} != set(settings["experts"]):
106
+ raise ValueError("成员凭证名单须与参与者名单一致")
107
+ for member in members:
108
+ expected = "online" if settings["experts"][member["name"]] == "online" else (
109
+ "runner" if payload.get("runtime_mode") == "automatic" else "manual")
110
+ if member["access"] != expected:
111
+ raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
112
+ return members
113
+
114
+
115
+ def sync_discussion_members(connection, room_id, payload, settings):
116
+ """Keep existing credentials; require fresh credentials for added or changed members."""
117
+ current = {row["name"]: row for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active'", (room_id,))}
118
+ supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
119
+ run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
120
+ for name, capability in settings["experts"].items():
121
+ access = "online" if capability == "online" else ("runner" if run and run[0] == "automatic" else "manual")
122
+ old = current.get(name)
123
+ if old and (old["access"] == "online") == (access == "online"):
124
+ if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
125
+ raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
126
+ continue
127
+ if name not in supplied or supplied[name]["access"] != access:
128
+ raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
129
+ member = supplied[name]
130
+ hashed = token_hash(member["token"])
131
+ if connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed, room_id, hashed)).fetchone():
132
+ raise ValueError("新成员凭证不能复用已有凭证")
133
+ connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
134
+ insert_member(connection, room_id, member, int(time.time()))
135
+ for name in current.keys() - settings["experts"].keys():
136
+ connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
137
+ connection.execute("UPDATE room_members SET role=CASE WHEN name=? THEN 'host' ELSE 'member' END WHERE room_id=?", (settings["host"], room_id))
95
138
 
96
139
 
97
140
  def sync_participants(connection, room_id):
98
- """Mirror non-removed members into the shared participant list the page already shows."""
99
- names = [row[0] for row in connection.execute("SELECT name FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))]
100
- connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?",
101
- (json.dumps({name: "cli" for name in names}, ensure_ascii=False), room_id))
141
+ """Mirror member capabilities and the active host into shared room metadata."""
142
+ members = connection.execute("SELECT name,access,role,status FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)).fetchall()
143
+ experts = {row["name"]: "online" if row["access"] == "online" else "cli" for row in members}
144
+ host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
145
+ connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
146
+ (json.dumps(experts, ensure_ascii=False), host, room_id))
102
147
 
103
148
 
104
149
  def execution_minutes(connection, room_id):
@@ -163,14 +208,6 @@ def free_view(connection, room_id):
163
208
  "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": MAX_AUTO_HOPS}
164
209
 
165
210
 
166
- def require_owner(connection, room_id, token):
167
- """Accept only the room control credential; declared names never grant ownership."""
168
- row = connection.execute("SELECT control_hash FROM room_modes WHERE room_id=? AND mode='free'", (room_id,)).fetchone()
169
- hashed = token_hash(token)
170
- if not row or not hashed or not hmac.compare_digest(hashed, row[0]):
171
- raise PermissionError("需要本房间的控制凭据;没有凭据只能阅读")
172
-
173
-
174
211
  def require_member(connection, room_id, token, name=None):
175
212
  """Resolve an active member strictly from its own credential."""
176
213
  hashed = token_hash(token)
@@ -533,13 +570,26 @@ def add_member(connection, room_id, token, payload, now):
533
570
  require_owner(connection, room_id, token)
534
571
  begin_write(connection)
535
572
  member = member_value(payload)
536
- if connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone():
573
+ existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
574
+ if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]) and all(
575
+ existing[key] == member[key] for key in ("adapter", "access", "session_mode")):
576
+ return free_view(connection, room_id)
577
+ if existing:
537
578
  raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
579
+ require_fresh_member_token(connection, room_id, member["token"])
538
580
  insert_member(connection, room_id, member, now)
539
581
  sync_participants(connection, room_id)
540
582
  return free_view(connection, room_id)
541
583
 
542
584
 
585
+ def require_fresh_member_token(connection, room_id, token):
586
+ """Prevent owner or another member's credential from being reused for a new identity."""
587
+ hashed = token_hash(token)
588
+ if not hashed or connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?",
589
+ (room_id, hashed, room_id, hashed)).fetchone():
590
+ raise ValueError("须提供独立的新成员凭证")
591
+
592
+
543
593
  def member_action(connection, room_id, member_id, token, payload):
544
594
  """Owner sends a member away, brings it back with a new credential, or removes it.
545
595
 
@@ -554,8 +604,11 @@ def member_action(connection, room_id, member_id, token, payload):
554
604
  connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
555
605
  elif action == "active":
556
606
  hashed = token_hash(payload.get("token"))
607
+ if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
608
+ return free_view(connection, room_id)
557
609
  if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
558
610
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
611
+ require_fresh_member_token(connection, room_id, payload.get("token"))
559
612
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
560
613
  elif action == "role":
561
614
  role = payload.get("role")
@@ -144,18 +144,65 @@ def require_control(connection, room_id, token):
144
144
  return row
145
145
 
146
146
 
147
+ def require_owner(connection, room_id, token):
148
+ """Require the persistent room owner credential, independent of execution mode."""
149
+ hashed = token_hash(token)
150
+ row = connection.execute("SELECT * FROM room_modes WHERE room_id=? AND control_hash=?",
151
+ (room_id, hashed)).fetchone() if hashed else None
152
+ if not row:
153
+ raise PermissionError("需要本房间的控制凭据;没有凭据只能阅读")
154
+ return row
155
+
156
+
157
+ def write_actor(operation, payload):
158
+ """Use the same message attribution defaults as persisted message details."""
159
+ if operation == "messages":
160
+ return payload.get("submitted_by", payload.get("author") if payload.get("source") == "cli" else "用户")
161
+ return payload.get("actor", "用户")
162
+
163
+
164
+ def authorize_participant(connection, room_id, identity, operation, payload):
165
+ """Limit authenticated participants to their identity and assigned host duties."""
166
+ actor = write_actor(operation, payload)
167
+ if actor != identity["name"]:
168
+ raise PermissionError("凭据与实际参与身份不符")
169
+ if operation == "messages":
170
+ author = payload.get("author")
171
+ online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
172
+ (room_id, author)).fetchone()
173
+ if author != actor and not (online and identity["role"] in ("owner", "host")):
174
+ raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
175
+ elif operation == "floor":
176
+ speaker = payload.get("speaker", actor)
177
+ online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
178
+ (room_id, speaker)).fetchone()
179
+ if identity["role"] != "owner" and speaker != actor and not (identity["role"] == "host" and online):
180
+ raise PermissionError("只能操作本人发言权")
181
+ elif operation != "attachments" and identity["role"] != "owner":
182
+ if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
183
+ raise PermissionError("本成员没有此管理权限")
184
+ if operation == "topic":
185
+ if any(field in payload for field in ("host", "experts", "members")):
186
+ raise PermissionError("只有房主可以修改成员和主持人")
187
+
188
+
147
189
  def authorize_write(connection, room_id, token, operation, payload):
148
190
  """Check a capability under the same writer lock as the following mutation."""
149
191
  begin_write(connection)
150
192
  run = connection.execute("SELECT * FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
151
- if not run:
152
- return
153
193
  hashed = token_hash(token)
154
- if hashed and hashed == run["control_hash"]:
194
+ owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
195
+ if owner:
196
+ authorize_participant(connection, room_id, {"name": "用户", "role": "owner"}, operation, payload)
155
197
  return
156
- actor = payload.get("actor", payload.get("submitted_by", payload.get("author", "用户")))
157
- if actor == "用户":
198
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access<>'online'",
199
+ (room_id, hashed)).fetchone() if hashed else None
200
+ if member and (not run or run["mode"] == "manual"):
201
+ authorize_participant(connection, room_id, member, operation, payload)
158
202
  return
203
+ if not run:
204
+ raise PermissionError("需要房主或本成员的有效凭据;没有凭据只能阅读")
205
+ actor = write_actor(operation, payload)
159
206
  work_seconds = execution_seconds(connection, room_id)
160
207
  event = connection.execute("""SELECT * FROM discussion_events WHERE room_id=? AND token_hash=?
161
208
  AND run_id=? AND generation=? AND status='started' AND lease_until>?
@@ -165,6 +212,7 @@ def authorize_write(connection, room_id, token, operation, payload):
165
212
  raise PermissionError("执行资格无效、已结束或已到期;请重新读取运行状态")
166
213
  if actor != event["participant"]:
167
214
  raise PermissionError("执行凭据与实际参与身份不符")
215
+ authorize_participant(connection, room_id, {"name": event["participant"], "role": event["role"]}, operation, payload)
168
216
  if operation == "messages":
169
217
  if payload.get("author") != actor:
170
218
  raise PermissionError("自动执行不能代发其他参与者")
@@ -182,6 +230,7 @@ def authorize_write(connection, room_id, token, operation, payload):
182
230
  def configure_runtime(connection, room_id, token, payload):
183
231
  """Apply one versioned, explicit mode or lifecycle action."""
184
232
  begin_write(connection)
233
+ require_owner(connection, room_id, token)
185
234
  current = runtime_view(connection, room_id)
186
235
  if payload.get("revision") != current["revision"]:
187
236
  raise FileExistsError("运行状态已变化,请重新读取 revision")
@@ -210,6 +259,8 @@ def configure_runtime(connection, room_id, token, payload):
210
259
  if action in ("pause", "stop"):
211
260
  connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=? AND phase<>'concluded'", (room_id,))
212
261
  connection.execute("UPDATE discussion_runtime SET status=? WHERE room_id=?", (status, room_id))
262
+ connection.execute("UPDATE room_members SET access=? WHERE room_id=? AND access<>'online'",
263
+ ("runner" if mode == "automatic" else "manual", room_id))
213
264
  return runtime_view(connection, room_id)
214
265
 
215
266
 
@@ -498,7 +498,7 @@ BEGIN
498
498
  UPDATE discussion_pauses SET paused_seconds=paused_seconds+MAX(0,CAST(strftime('%s','now') AS INTEGER)-paused_at),paused_at=0
499
499
  WHERE room_id=NEW.room_id AND paused_at>0;
500
500
  END;
501
- -- Free rooms: members act with their own credential; each @ of a member is a durable request.
501
+ -- Both room kinds register members; free rooms additionally persist requests and executions.
502
502
  CREATE TABLE IF NOT EXISTS room_members (
503
503
  id INTEGER PRIMARY KEY AUTOINCREMENT,
504
504
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
@@ -513,6 +513,7 @@ CREATE TABLE IF NOT EXISTS room_members (
513
513
  session_mode TEXT NOT NULL DEFAULT 'new'
514
514
  );
515
515
  CREATE UNIQUE INDEX IF NOT EXISTS member_name ON room_members(room_id,name) WHERE status<>'removed';
516
+ CREATE UNIQUE INDEX IF NOT EXISTS member_credential ON room_members(room_id,token_hash);
516
517
  CREATE TABLE IF NOT EXISTS member_executions (
517
518
  id INTEGER PRIMARY KEY AUTOINCREMENT,
518
519
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
package/backend/server.py CHANGED
@@ -21,7 +21,7 @@ from urllib.parse import parse_qs, quote, urlsplit
21
21
 
22
22
  from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
23
23
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
24
- room_mode, save_free_room, view_execution)
24
+ discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
25
25
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
26
26
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
27
27
  LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
@@ -76,7 +76,8 @@ ROOM_BACKUP_QUERIES = {
76
76
  **{table: f"SELECT * FROM {table} WHERE message_id IN (SELECT id FROM messages WHERE room_id=?) ORDER BY message_id"
77
77
  for table in ("message_details", "message_summaries", "message_timings")},
78
78
  }
79
- # Only free rooms carry these tables; credential hashes are never exported.
79
+ ROOM_BACKUP_QUERIES["room_creations"] = "SELECT submission_id,room_id FROM room_creations WHERE room_id=?"
80
+ # Shared membership plus free-room requests; credential hashes are never exported.
80
81
  FREE_BACKUP_QUERIES = {
81
82
  "room_modes": "SELECT room_id,mode FROM room_modes WHERE room_id=?",
82
83
  "room_members": "SELECT id,room_id,name,adapter,role,status,read_until,created_at FROM room_members WHERE room_id=? ORDER BY id",
@@ -102,7 +103,7 @@ def room_backup(connection, room_id):
102
103
  A versioned backup containing original rows and their relationships.
103
104
  """
104
105
  connection.execute("BEGIN")
105
- queries = {**ROOM_BACKUP_QUERIES, **(FREE_BACKUP_QUERIES if room_mode(connection, room_id) == "free" else {})}
106
+ queries = {**ROOM_BACKUP_QUERIES, **FREE_BACKUP_QUERIES}
106
107
  tables = {table: [dict(row) for row in connection.execute(query, (room_id,))]
107
108
  for table, query in queries.items()}
108
109
  if not tables["rooms"]:
@@ -195,6 +196,8 @@ def require_room(connection, room_id):
195
196
  room["runtime"] = runtime_view(connection, room_id)
196
197
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
197
198
  room["mode"] = room_mode(connection, room_id)
199
+ room["members"] = [dict(row) for row in connection.execute(
200
+ "SELECT id,name,adapter,role,status,access,session_mode FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))]
198
201
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
199
202
  WHERE room_links.room_id=?""", (room_id,)).fetchone()
200
203
  room["parent_room"] = dict(parent) if parent else None
@@ -380,6 +383,7 @@ def update_topic(connection, room_id, payload):
380
383
  if settings["phase"] == "concluded" or turn["expert"] not in settings["experts"]:
381
384
  raise ValueError("请先完成或取消当前发言安排")
382
385
  connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
386
+ sync_discussion_members(connection, room_id, payload, settings)
383
387
  save_topic_settings(connection, room_id, settings)
384
388
  return require_room(connection, room_id)
385
389
 
@@ -547,17 +551,19 @@ def creation_request(settings):
547
551
 
548
552
 
549
553
  def create_room(connection, payload, control_token=""):
550
- """Create one room per explicit identifier, retaining legacy unkeyed requests."""
554
+ """Create a credential-protected room, binding retries to the original credentials."""
551
555
  if payload.get("mode", "brainstorm") not in ("free", "brainstorm"):
552
556
  raise ValueError("房间模式须为 free 或 brainstorm")
553
557
  free = payload.get("mode") == "free"
554
558
  members = member_values(payload.get("members")) if free else []
555
559
  if free:
556
- payload = {**payload, "host": "用户", "phase": "discussing", "experts": {member["name"]: "cli" for member in members}}
560
+ payload = {**payload, "phase": "discussing", "experts": {member["name"]: "online" if member["access"] == "online" else "cli" for member in members}}
557
561
  settings = topic_values(payload)
562
+ if not free:
563
+ members = discussion_members(payload, settings)
558
564
  automatic = payload.get("runtime_mode") == "automatic"
559
- if payload.get("runtime_mode", "manual") not in ("manual", "automatic") or (automatic and not token_hash(control_token)):
560
- raise ValueError("自动建房须提供有效的运行模式和控制凭据")
565
+ if payload.get("runtime_mode", "manual") not in ("manual", "automatic") or not token_hash(control_token):
566
+ raise ValueError("建房须提供有效的运行模式和房主控制凭据")
561
567
  if free and (automatic or not token_hash(control_token)):
562
568
  raise ValueError("自由房间须提供房间控制凭据,且不使用讨论运行模式")
563
569
  parent_id = payload.get("parent_room_id")
@@ -568,8 +574,10 @@ def create_room(connection, payload, control_token=""):
568
574
  if folder_id is not None and not isinstance(folder_id, str):
569
575
  raise ValueError("文件夹编号必须是文本或 null")
570
576
  request_text = creation_request(settings) + (":automatic" if automatic else "")
577
+ request_text += ":access:" + token_hash(control_token) + ":" + json.dumps(
578
+ [[member["name"], member["adapter"], member["access"], member["session_mode"], token_hash(member["token"])] for member in members], ensure_ascii=False)
571
579
  if free:
572
- request_text += ":free:" + json.dumps([[member["name"], member["adapter"]] for member in members], ensure_ascii=False)
580
+ request_text += ":free"
573
581
  if parent_id is not None:
574
582
  request_text += ":parent:" + parent_id
575
583
  if folder_id is not None:
@@ -589,8 +597,8 @@ def create_room(connection, payload, control_token=""):
589
597
  if identifier:
590
598
  connection.execute("INSERT INTO room_creations (submission_id,room_id,request_text) VALUES (?,?,?)", (identifier, room_id, request_text))
591
599
  save_topic_settings(connection, room_id, settings)
592
- if free:
593
- save_free_room(connection, room_id, control_token, members, utc_now())
600
+ save_room_access(connection, room_id, control_token, members,
601
+ {"mode": "free" if free else "brainstorm", "host": settings["host"], "created_at": utc_now()})
594
602
  if parent_id is not None:
595
603
  connection.execute("INSERT INTO room_links (room_id,parent_id,created_at) VALUES (?,?,?)", (room_id, parent_id, utc_now()))
596
604
  if automatic:
@@ -1169,15 +1177,27 @@ def turn_text(turn, room):
1169
1177
  def protocol_text(room):
1170
1178
  """Describe explicit CLI and host operations separately from topic content."""
1171
1179
  base = f"/api/rooms/{room['id']}"
1180
+ if room["mode"] == "free":
1181
+ return "\n\n".join([
1182
+ "# 自由协作参与协议", f"当前主持人:{room['host']}。没有讨论发言权、正式安排或轮次。",
1183
+ "先用 room view 读取成员、职责和 free.requests,再按需读取 message list / message view 原文;通知只给对象、问题和入口。",
1184
+ "用户写入和成员管理须房主控制凭据;本地成员写入须本人的 --access-file,不能借用用户或其他成员身份。凭据文件私下交付,不公开 token。",
1185
+ "runner 成员等待本机接入程序交付有效任务,不另建定时。用 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答;不执行 execution enter。执行失效后保留草稿,不能去掉编号改作普通发言。",
1186
+ "manual 成员可由人工或软件原生定时触发,用本人成员文件自行提交。回答带 --request 请求编号(可重复)关闭自己的请求,不带则为普通发言。不得编造 execution 编号。",
1187
+ "online 成员只读,在原聊天回答,由用户以房主凭据代发原文;作者仍是在线成员、提交者为用户,不能用成员凭证自行写入。复制说明不表示已送达或接入。",
1188
+ "获授 host 职责的本地成员可在回答中用 --to 点名其他成员,不能点名自己或扩大权限;route 表示分配任务,handoff 表示派出的成员已结束。链上 AI 请求受房间预算限制,到限等待用户。",
1189
+ f"附件使用 attachment upload/download;上传使用本人成员文件,消息带 attachment_ids。原始接口为 {base}/attachments 与 {base}/messages,自动成员在回答完成前上传。",
1190
+ "每次新内容用新 submission_id,同次重试沿用编号、原文、凭据和执行/请求关联;失败先核对服务端结果,不换身份或编号绕过。用户打断、成员离开或移除后停止本次工作。",
1191
+ ])
1172
1192
  return "\n\n".join([
1173
1193
  "# 本地 CLI 读写与主持协议", f"当前主持人:{room['host']}。以下为留言板接口,不是模型服务 API。",
1174
1194
  "写入使用 POST,通常为 Content-Type: application/json;附件上传使用 application/octet-stream 原始字节。主持操作填写 actor 为当前主持人或用户;这些是声明身份,不是认证凭据。",
1175
1195
  f"附件:POST {base}/attachments?actor=身份&file_name=文件名&submission_id=新编号,原文件最多 20 MiB;GET 同路径默认列已发送附件,加 submission_id 精确核对上传。GET {base}/attachments/ID 读元数据,/ID/content 下载原文件。消息可带 attachment_ids(最多 10 个有序编号);只关联同房间、同提交者的未使用附件,可只有附件无正文。自动任务须在回答完成前上传;消息查询包含附件,按需下载后由当前软件读取,不自动解析。",
1176
1196
  "讨论上限:room.limits 返回轮次、总时长、单次执行、到限方式、收束预留和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;附 force:true 可在进行中提前进入收束,由主持人在收束预留时间内总结。到限停止新专家安排与观点;总结由真实主持人完成,预留计入总时长;暂停期间不计时也不消耗总时长。",
1177
- "运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。启用保护的房间写入须带 X-Discussion-Token;CLI 用 --access-file 读取本机私有文件,不公开令牌。自动任务先 execution enter,创建安排或保存关联回答即完成本步;不创建原生定时任务。旧执行在暂停、停止、换代、租约到期后失效。手动接入沿用原流程;受保护房间仍须用户提供有效的凭据文件。运行控制用 run view/start/pause/stop/configure;字段查 CLI schema。",
1197
+ "运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。所有房间写入须带 X-Discussion-Token,CLI 用 --access-file 读取私有文件。用户及运行控制使用房主凭据,手动本地成员使用自己的成员凭证,不能相互冒充;在线成员只读,由房主或讨论主持人代发。自动讨论先 execution enter,创建安排或保存关联回答即完成本步,不创建原生定时任务;持久成员凭证不能替代自动执行,暂停、停止、换代、租约到期后的旧执行失效。run view/start/pause/stop/configure 字段查 CLI schema。",
1178
1198
  f"发言权:GET {base}/floor 返回 holder、next_speaker、revision;POST 同路径,字段 actor、speaker(默认 actor)、action=take/return、revision(刚读到的整数版本)。仅无人持有且 next_speaker 为本人时可领取;持有者可主动归还,下一位回到主持人。版本变化返回 409,先读回核对。主持人仅可代操作 online 专家,cli 专家自行操作;用户可协助。",
1179
1199
  "新房间由主持人持有发言权。创建安排须主持人持有,保存成功即归还并点名该专家;AI 的 opinion、moderation、receipt 都须实际作者持有,代发不豁免,opinion 必须关联有效 turn_id。消息保存成功自动归还,下一位为主持人。失败不归还,同编号重试不再次交接。用户本人发言不受发言权限制,也不改变发言权;@ 不改变顺序。维护状态、阶段及通知回执不占用发言权。取消安排须主持人持有,用户可直接取消失效安排;取消后归还给主持人领取。",
1180
- '创建话题:POST /api/rooms;字段 title、goal、user_request(用户原文)、background、boundaries、host、experts、submission_id(新 UUID),例如 experts={"Codex":"cli","Gemini":"online"}。AI 主持人必须配置为 cli。同编号对照首次创建资料,重试不覆盖后来修改的房间;旧客户端不带编号时没有幂等保护。',
1200
+ '创建话题:POST /api/rooms,携带房主控制凭据;字段 title、goal、user_request、background、boundaries、host、experts、members、submission_id(新 UUID)。members 与 experts 名单一致,各项含 name、独立 token、access(手动本地 manual、自动 runner、在线 online)。AI 主持人必须为本地可写成员。CLI room create --access-file 私有房主文件 在请求前保存凭据,确认后返回各本地成员文件路径;同编号重试沿用原资料和全部凭据。名单、主持职责变更仅房主可做。',
1181
1201
  f"查询提交:GET /api/rooms?submission_id=编号 查建房结果;GET {base}/messages?submission_id=编号 或 {base}/turns?submission_id=编号 查消息或安排。查询成功且列表为空表示尚未查到,读取失败不表示未写入。",
1182
1202
  f"增量读取:GET {base}/messages?after=已读消息编号,每次最多 20 条,按 next_after 续读,has_more 表示还有一批;可加 kind=opinion/moderation/receipt、author、mention、q。after 不与翻页混用,过滤后的游标只代表该查询范围。",
1183
1203
  f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/reviewing/paused/concluded)、focus。收束前先完成或取消当前安排。暂停保留安排;恢复阶段后再继续。",
@@ -1400,7 +1420,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1400
1420
  """Forward authenticated browser requests to the shared Node entrypoint and pin their website."""
1401
1421
  site = self.local_request_site(method)
1402
1422
  route = parts[2:]
1403
- if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"]):
1423
+ if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["models"]):
1404
1424
  return self.respond({"ok": True})
1405
1425
  if route == ["settings"] and method in ("GET", "POST"):
1406
1426
  command = "settings"
@@ -1410,6 +1430,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1410
1430
  command = "room-config"
1411
1431
  elif route == ["room-models"] and method == "POST":
1412
1432
  command = "room-models"
1433
+ elif route == ["models"] and method == "POST":
1434
+ command = "models"
1413
1435
  elif route == ["room-member"] and method == "POST":
1414
1436
  command = "room-member"
1415
1437
  else: