ai_co_discuss 0.2.36 → 0.2.38
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/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/backend/free_room.py +66 -1
- package/backend/schema.sql +7 -0
- package/backend/server.py +28 -4
- package/docs/architecture.md +2 -1
- package/docs/cli.md +2 -0
- package/docs/runner.md +441 -441
- package/docs/workflow.md +6 -4
- package/package.json +1 -1
- package/runner/api_participant.mjs +15 -1
- package/runner/discuss.py +14 -2
- package/runner/discussion_runner.mjs +31 -8
- package/runner/discussion_service.mjs +2 -2
- package/runner/scheduler.mjs +19 -0
- package/web/app.js +2 -2
- package/web/modern.css +1 -1
- package/web/modern.js +1247 -1183
|
@@ -38,7 +38,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
|
|
|
38
38
|
|
|
39
39
|
自由房间的普通成员可在回答中用 `--to 成员名` 或正文 `@成员` 提出回应建议,不能点名自己;这不会派发任务。网页显示“建议请 X 回应”,只有房主点击“转为请求”后才建立关联原回答的新用户任务链。任务的 `request_messages` 可能指向已读游标之前的原回答,须用 `message view ROOM ID` 补读;用户确认消息不代替原建议。不要把建议显示或复制入口当作已经通知;代码与引用里的正文 @ 不生成建议。
|
|
40
40
|
|
|
41
|
-
**手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token
|
|
41
|
+
**手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名,在线成员没有自行写入资格。手动本地成员首次读取房间后立即执行一次 `member register ROOM --as 本人 --access-file FILE` 注册;未注册前网站显示“待注册”,不算到场,也不会被本机服务接管。会话 ID 由 CLI 从宿主会话自动附带,不自行填写;凭证重新签发后须重新注册。
|
|
42
42
|
|
|
43
43
|
## 先读什么
|
|
44
44
|
|
package/backend/free_room.py
CHANGED
|
@@ -229,6 +229,7 @@ def free_view(connection, room_id):
|
|
|
229
229
|
members = []
|
|
230
230
|
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
231
231
|
member = {key: row[key] for key in MEMBER_FIELDS}
|
|
232
|
+
member["registered_at"] = registered_at(connection, row)
|
|
232
233
|
latest = connection.execute("SELECT * FROM member_executions WHERE member_id=? ORDER BY id DESC LIMIT 1",
|
|
233
234
|
(row["id"],)).fetchone()
|
|
234
235
|
member["execution"] = public_execution(latest)
|
|
@@ -258,6 +259,30 @@ def require_member(connection, room_id, token, name=None):
|
|
|
258
259
|
return row
|
|
259
260
|
|
|
260
261
|
|
|
262
|
+
def registered_at(connection, member):
|
|
263
|
+
"""Return when the member registered with its current credential, or '' while it has not."""
|
|
264
|
+
row = connection.execute("SELECT registered_at FROM member_registrations WHERE member_id=? AND token_hash=?",
|
|
265
|
+
(member["id"], member["token_hash"])).fetchone()
|
|
266
|
+
return row[0] if row else ""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def register_member(connection, room_id, token, now):
|
|
270
|
+
"""A manual member announces itself with its own credential; repeating it keeps the first time.
|
|
271
|
+
|
|
272
|
+
The session ID is not part of the request: the CLI attaches it to every call and the room records it separately.
|
|
273
|
+
"""
|
|
274
|
+
begin_write(connection)
|
|
275
|
+
member = require_member(connection, room_id, token)
|
|
276
|
+
if member["access"] != "manual":
|
|
277
|
+
raise ValueError("只有手动接入的成员需要注册")
|
|
278
|
+
stamp = registered_at(connection, member) or now
|
|
279
|
+
connection.execute("""INSERT INTO member_registrations (member_id,room_id,token_hash,registered_at) VALUES (?,?,?,?)
|
|
280
|
+
ON CONFLICT(member_id) DO UPDATE SET token_hash=excluded.token_hash,registered_at=excluded.registered_at""",
|
|
281
|
+
(member["id"], room_id, member["token_hash"], stamp))
|
|
282
|
+
reported = connection.execute("SELECT client FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
|
|
283
|
+
return {"member": member["name"], "registered_at": stamp, "session_client": reported[0] if reported else ""}
|
|
284
|
+
|
|
285
|
+
|
|
261
286
|
def authorize_free_write(connection, room_id, token, operation, payload):
|
|
262
287
|
"""Decide who is writing: the owner, or one member posting its own answer.
|
|
263
288
|
|
|
@@ -784,8 +809,45 @@ def save_discussion_experts(connection, room_id, experts):
|
|
|
784
809
|
connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
|
|
785
810
|
|
|
786
811
|
|
|
812
|
+
def current_round_speakers(connection, room_id, experts):
|
|
813
|
+
"""Experts who have given an opinion in the latest unfinished round (empty when it just completed)."""
|
|
814
|
+
spoken = set()
|
|
815
|
+
rows = connection.execute("""SELECT messages.author FROM messages
|
|
816
|
+
JOIN message_details AS details ON details.message_id=messages.id AND details.kind='opinion'
|
|
817
|
+
WHERE messages.room_id=? ORDER BY messages.id""", (room_id,))
|
|
818
|
+
for row in rows:
|
|
819
|
+
if row["author"] not in experts:
|
|
820
|
+
continue
|
|
821
|
+
spoken.add(row["author"])
|
|
822
|
+
if len(spoken) == len(experts):
|
|
823
|
+
spoken = set()
|
|
824
|
+
return spoken
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def place_invited_expert(connection, room_id, name):
|
|
828
|
+
"""Move the just-queued newcomer to where the current round completes: after the last queued expert who
|
|
829
|
+
has not spoken this round, before the first one who would speak again. Rows keep their ids; only who and what shift."""
|
|
830
|
+
rows = connection.execute("SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,)).fetchall()
|
|
831
|
+
if len(rows) < 2 or rows[-1]["expert"] != name:
|
|
832
|
+
return
|
|
833
|
+
_, experts, _ = discussion_roster(connection, room_id)
|
|
834
|
+
spoken = current_round_speakers(connection, room_id, experts)
|
|
835
|
+
open_turn = connection.execute("SELECT expert FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')", (room_id,)).fetchone()
|
|
836
|
+
if open_turn:
|
|
837
|
+
spoken.add(open_turn["expert"])
|
|
838
|
+
slot = len(rows) - 1
|
|
839
|
+
for index, row in enumerate(rows[:-1]):
|
|
840
|
+
if row["expert"] in spoken:
|
|
841
|
+
slot = index
|
|
842
|
+
break
|
|
843
|
+
spoken.add(row["expert"])
|
|
844
|
+
ordered = rows[:slot] + [rows[-1]] + rows[slot:-1]
|
|
845
|
+
for target, source in zip(rows, ordered):
|
|
846
|
+
connection.execute("UPDATE discussion_turns SET expert=?,question=? WHERE id=? AND status='queued'", (source["expert"], source["question"], target["id"]))
|
|
847
|
+
|
|
848
|
+
|
|
787
849
|
def queue_invited_expert(connection, room_id, name, now):
|
|
788
|
-
"""
|
|
850
|
+
"""Queue a new expert in an existing live plan at its round-completing slot, without changing the current speaker or hold."""
|
|
789
851
|
connection.execute("""INSERT INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
|
|
790
852
|
SELECT settings.room_id,member.name,
|
|
791
853
|
CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
|
|
@@ -797,6 +859,7 @@ def queue_invited_expert(connection, room_id, name, now):
|
|
|
797
859
|
AND NOT EXISTS (SELECT 1 FROM discussion_limits WHERE room_id=settings.room_id AND closing_at>0)
|
|
798
860
|
AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
|
|
799
861
|
(now, room_id, name))
|
|
862
|
+
place_invited_expert(connection, room_id, name)
|
|
800
863
|
|
|
801
864
|
|
|
802
865
|
def add_discussion_member(connection, room_id, token, payload, now):
|
|
@@ -873,6 +936,8 @@ def adopt_manual_member(connection, room_id, member, payload):
|
|
|
873
936
|
"""Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
|
|
874
937
|
reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
|
|
875
938
|
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
939
|
+
if not registered_at(connection, member):
|
|
940
|
+
raise ValueError("成员尚未注册,不能转为托管")
|
|
876
941
|
if not reported or reported["session_id"] != payload.get("session_id") or payload.get("adapter") not in ADOPTABLE_ADAPTERS:
|
|
877
942
|
raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
|
|
878
943
|
if member["access"] != "manual" or member["status"] != "active" or not run or run["mode"] != "automatic":
|
package/backend/schema.sql
CHANGED
|
@@ -546,6 +546,13 @@ CREATE TABLE IF NOT EXISTS member_sessions (
|
|
|
546
546
|
session_id TEXT NOT NULL,
|
|
547
547
|
client TEXT NOT NULL
|
|
548
548
|
);
|
|
549
|
+
-- A manual member's own registration; it is bound to the credential it used, so a re-issued credential must register again.
|
|
550
|
+
CREATE TABLE IF NOT EXISTS member_registrations (
|
|
551
|
+
member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
|
|
552
|
+
room_id TEXT NOT NULL,
|
|
553
|
+
token_hash TEXT NOT NULL,
|
|
554
|
+
registered_at TEXT NOT NULL
|
|
555
|
+
);
|
|
549
556
|
CREATE TABLE IF NOT EXISTS member_executions (
|
|
550
557
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
551
558
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
package/backend/server.py
CHANGED
|
@@ -23,7 +23,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
|
|
|
23
23
|
inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
|
|
24
24
|
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
25
25
|
from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
|
|
26
|
-
from .free_room import adopt_recommendation, recommend_member
|
|
26
|
+
from .free_room import adopt_recommendation, recommend_member, register_member
|
|
27
27
|
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
28
28
|
from .room_import import import_room, imported_attachment
|
|
29
29
|
from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
|
|
@@ -211,13 +211,16 @@ def require_room(connection, room_id):
|
|
|
211
211
|
room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
|
|
212
212
|
room["mode"] = room_mode(connection, room_id)
|
|
213
213
|
room["members"] = [dict(row) for row in connection.execute(
|
|
214
|
-
"""SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client
|
|
215
|
-
|
|
214
|
+
"""SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client,
|
|
215
|
+
COALESCE(r.registered_at,'') AS registered_at
|
|
216
|
+
FROM room_members AS m LEFT JOIN member_sessions AS s ON s.member_id=m.id
|
|
217
|
+
LEFT JOIN member_registrations AS r ON r.member_id=m.id AND r.token_hash=m.token_hash WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,))]
|
|
216
218
|
option = connection.execute("SELECT named_followup FROM discussion_options WHERE room_id=?", (room_id,)).fetchone()
|
|
217
219
|
# Rooms created before the option keep the host's named follow-ups.
|
|
218
220
|
room["named_followup"] = bool(option[0]) if option else True
|
|
219
221
|
room["scheduler_decisions"] = [dict(row) for row in connection.execute(
|
|
220
222
|
"SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id DESC LIMIT ?", (room_id, RECENT_DECISIONS))][::-1]
|
|
223
|
+
room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
|
|
221
224
|
parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
|
|
222
225
|
WHERE room_links.room_id=?""", (room_id,)).fetchone()
|
|
223
226
|
room["parent_room"] = dict(parent) if parent else None
|
|
@@ -264,6 +267,23 @@ def require_floor(room, speaker):
|
|
|
264
267
|
raise FileExistsError("请先领取本人发言权;他人持有或尚未被点名时不能发言或安排")
|
|
265
268
|
|
|
266
269
|
|
|
270
|
+
def host_arrangements(connection, room_id, decisions):
|
|
271
|
+
"""Each plan the host created (turns saved in one batch), anchored after the latest answer before it.
|
|
272
|
+
|
|
273
|
+
Invitation turns are not plans, and a plan the smart scheduler applied is already shown by its own decision.
|
|
274
|
+
"""
|
|
275
|
+
plans, latest_answer = [], 0
|
|
276
|
+
for row in connection.execute("SELECT id,expert,question,answer_id,created_at,submission_id FROM discussion_turns WHERE room_id=? ORDER BY id", (room_id,)):
|
|
277
|
+
if not row["submission_id"].startswith("invite:"):
|
|
278
|
+
if plans and plans[-1]["created_at"] == row["created_at"]:
|
|
279
|
+
plans[-1]["turns"].append({"expert": row["expert"], "question": row["question"]})
|
|
280
|
+
else:
|
|
281
|
+
plans.append({"id": row["id"], "after_message_id": latest_answer, "created_at": row["created_at"], "turns": [{"expert": row["expert"], "question": row["question"]}]})
|
|
282
|
+
latest_answer = max(latest_answer, row["answer_id"] or 0)
|
|
283
|
+
applied = {item["after_message_id"] for item in decisions if item["applied"]}
|
|
284
|
+
return [plan for plan in plans if plan["after_message_id"] and plan["after_message_id"] not in applied][-RECENT_DECISIONS:]
|
|
285
|
+
|
|
286
|
+
|
|
267
287
|
def discussion_rounds(connection, room_id, experts):
|
|
268
288
|
"""Group complete history metadata until every configured expert has spoken.
|
|
269
289
|
|
|
@@ -549,7 +569,7 @@ def manage_room(connection, room_id, action, payload):
|
|
|
549
569
|
|
|
550
570
|
def delete_room_records(connection, room_id):
|
|
551
571
|
"""Remove a room's dependent records in the caller's transaction."""
|
|
552
|
-
for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
|
|
572
|
+
for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
|
|
553
573
|
"discussion_options", "scheduler_decisions"):
|
|
554
574
|
connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
|
|
555
575
|
connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
@@ -1614,6 +1634,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1614
1634
|
record_reported_session(connection, room_id, token, {"session_id": self.headers.get("X-Discussion-Session"),
|
|
1615
1635
|
"session_client": self.headers.get("X-Discussion-Session-Client")})
|
|
1616
1636
|
connection.commit()
|
|
1637
|
+
if parts[3:] == ["register"] and method == "POST":
|
|
1638
|
+
result = register_member(connection, room_id, token, utc_now())
|
|
1639
|
+
connection.commit()
|
|
1640
|
+
return self.respond(result)
|
|
1617
1641
|
free = room_mode(connection, room_id) == "free"
|
|
1618
1642
|
if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["recommendations"]):
|
|
1619
1643
|
return self.handle_free_api(connection, parts, method)
|
package/docs/architecture.md
CHANGED
|
@@ -220,7 +220,7 @@ Python 通过进程内条件变量唤醒订阅者,不占用数据库连接,
|
|
|
220
220
|
## 讨论选项与智能调度记录
|
|
221
221
|
|
|
222
222
|
- `discussion_options`:建房时的讨论选项,目前只有 `named_followup`(0/1,建房 `named_followup` 布尔值,默认 false,计入建房幂等比对)。没有记录的旧房间视为 true;房间视图返回 `named_followup`。
|
|
223
|
-
- `scheduler_decisions`:讨论中每次智能调度判断的可见记录(`kind` 为 `interjection` / `round`,`label`、`confidence`、`applied`、`provider`、`after_message_id`),由接入程序用运行控制凭据 `POST scheduler-decisions` 写入,不是消息,不影响轮次、发言权与暂扣;房间视图 `scheduler_decisions` 返回最近 100
|
|
223
|
+
- `scheduler_decisions`:讨论中每次智能调度判断的可见记录(`kind` 为 `interjection` / `round`,`label`、`confidence`、`applied`、`provider`、`after_message_id`),由接入程序用运行控制凭据 `POST scheduler-decisions` 写入,不是消息,不影响轮次、发言权与暂扣;房间视图 `scheduler_decisions` 返回最近 100 条。房间视图另附 `host_arrangements`:由 `discussion_turns` 按同一批创建时间分组得到的主持人安排(不含邀请追加的开场安排,也不含已由智能调度写入决策的那一批),`after_message_id` 取该批之前最新的回答,网页在该回答后显示“主持人安排”卡片,逐条列出专家与主持人写的任务说明(`turns`,默认前 3 条,其余可展开),样式同智能调度卡片;没有可跟随的回答(讨论开场)时不显示。不新增表。
|
|
224
224
|
- 两表都是独立表,D1 重新应用 schema 即可;备份、导入(`after_message_id` 随消息重编号)与删除一并处理。智能调度代主持人执行的写入不新增权限,沿用该主持事件的执行凭据。
|
|
225
225
|
|
|
226
226
|
## 自动运行协议
|
|
@@ -249,6 +249,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
|
|
|
249
249
|
|
|
250
250
|
- **凭据**:建房时由客户端提供房间控制凭据和每位成员的成员凭证,服务端只存哈希。用户身份的房间写入(发言、附件、存档、恢复、删除、打断)都须控制凭据;房间排序和归入文件夹只改共享目录,与站点级文件夹操作一样不需要凭据;无凭据、错误凭据、成员凭证或他房凭据一律只读。成员凭证只能以该成员身份发言或上传附件。声明字段(`author`、`source`、localhost)不代替凭据。
|
|
251
251
|
- **成员**:`room_members` 以 `id` 为稳定主键,`access` 为 `runner`(接入程序投递)、`manual`(成员自行读写)或 `online`(只读、原文代发),`session_mode` 标记是否借用用户正在使用的会话。房主用控制凭据 `POST members` 运行中加入成员,`POST members/ID {action}` 让成员离开(`away`,作废其进行中执行与未投递请求)、请回(`active`,必须提供不同于原凭证的新凭证,旧凭证副本不再有效)或移除(`remove`,保留历史发言)。同一成员凭证不能复用于其他成员或房主;相同凭证与配置的邀请、请回重试返回已有结果。会话续接由本机接入程序负责,服务端不保存会话绑定;唯一例外是手动成员上报的会话:带凭据的请求可带请求头 `X-Discussion-Session` 与 `X-Discussion-Session-Client`,`message post` 也可在正文带 `session_id` 与 `session_client`(CLI 读取 `CLAUDE_CODE_SESSION_ID` 或 `CODEX_THREAD_ID`,取值 `claude` / `codex`),服务端在 `member_sessions` 保存该手动成员最近上报的会话,成员视图以 `reported_session` 暴露。房主用 `POST members/ID {action:"adopt", adapter:"claude", session_id}`(须与上报一致、仅全自动讨论中在场的手动成员)把成员改为 `runner`、`borrowed` 并清除上报,本机接入程序据此接管。两套后端行为一致。
|
|
252
|
+
- **手动成员注册**:`member_registrations`(`member_id`、`room_id`、`token_hash`、`registered_at`)记录手动成员自己发起的注册,`POST /api/rooms/ID/register` 只接受该成员自己的凭证(房主凭据与他人凭证返回 403),重复调用保留首次时间。记录绑定注册时的凭证哈希:请回换发新凭证后旧记录不再匹配,视图中的 `registered_at` 回到空串,须重新注册。会话 ID 不属于注册内容,仍由 CLI 头部采集进 `member_sessions`;`adopt` 要求已注册。讨论房间的成员视图与自由房间 `free.members` 都带 `registered_at`;新表为独立表,D1 重新应用 schema 即可。
|
|
252
253
|
- **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
|
|
253
254
|
- **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
|
|
254
255
|
- **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 runner 成员(没有则手动成员)接任;建房和每条房主消息前也做同样的补位,因此旧房间在下一条消息时自动获得主持人。房主用 `POST members/ID {action:"role", role:"host"}` 更换主持人(在线成员不能担任),把现任主持人改为 `member` 会被拒绝。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后,只要有一个完成就给主持人生成一次 `handoff` 请求,全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
|
package/docs/cli.md
CHANGED
|
@@ -157,6 +157,8 @@ codiscuss message post ROOM --as Claude --kind chat --execution 执行编号 --b
|
|
|
157
157
|
|
|
158
158
|
手动接入的成员没有执行编号:`room view` 的 `free.requests` 列出它的待处理请求,回答时用 `--request 请求编号`(可重复)关闭对应请求;不带 `--request` 的回答是普通发言,不关闭任何请求。`--request` 与 `--execution` 不能同用。
|
|
159
159
|
|
|
160
|
+
手动接入成员首次读取房间后执行一次 `member register ROOM --as 本人 --access-file FILE`:网站记录该成员已用自己的凭证注册,重复执行保留首次时间,房主凭据或他人凭证被拒绝(403),自动接入成员不需要注册(400)。命令不带会话 ID,CLI 在每次请求头中自动附带宿主会话(`CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`)。未注册的成员在网页显示“待注册”,也不会被本机服务接管。
|
|
161
|
+
|
|
160
162
|
主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。被点名的成员都结束后,主持人会再收到一次 `handoff` 请求。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
|
|
161
163
|
|
|
162
164
|
## 写入核对与错误处理
|