ai_co_discuss 0.4.7 → 0.4.8

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.
@@ -54,7 +54,8 @@
54
54
  - Goal:任务需要在房间空闲时持续自动推进、且目标和完成标准明确时,先 `goal` 读取状态和 `revision`,再 `goal --do start --goal 目标 --revision REV`(预算用房主默认值,你不能改);`--do pause|resume|end` 控制。
55
55
 
56
56
  - **兜底**:派出要跑较久的活(多集、多轮接力、循环评审)时,`checkin --minutes N`(5–120)让程序在房间还有进行中的工作时每 N 分钟给你一次“定时检查”,你据此确认没有卡住、跑偏或失败未处理;房间空闲、停止全部或换主持人后自动结束,`checkin --off` 提前关闭。定时检查要在你处理任务(有进行中的执行)时设置。另外,成员连续 10 分钟没有新输出时,程序会单独通知你。
57
- - **主动发言**:不是房间派来的任务、而是你需要主动说明或派活时(如定时检查发现问题、用户在你自己的窗口里要你发言),用参与技能“主动发言”一节的 `message post … --proactive`,主持人的主动发言可以同时 `--to` 派活或 `--continue`。
57
+ - **主动发言**:不是房间派来的任务、而是你需要主动说明或派活时(如定时检查发现问题、用户在你自己的窗口里要你发言、你自己的执行失效后结果需要补交),用参与技能“主动发言”一节的 `message post … --proactive`,不必等用户开口;主持人的主动发言可以同时 `--to` 派活或 `--continue`。你自己失败的执行也可以用 `host-control retry --execution N` 重试。
58
+ - **你自己失败时**:程序会发一条提醒唤醒你一次(提醒本身再失败就不再唤醒,需用户处理)。先核对自己会话里的进展:已有结果就用 `--execution 原编号` 补交;没做完就接着做完再补交,或 `retry` 重试;是模型额度或模型不可用造成的,可 `host-control model --member 你自己 --model M` 换模型,下次投递起生效。最后用本次执行简短回复这条提醒。
58
59
 
59
60
  `revision` 过期会被拒绝,重新读取后再提交。用户要求停下、换人或调整顺序时直接执行;你自己发现问题时先诊断再处理。每次控制都在回答里说明做了什么、为什么。以下仍由用户决定,需要时在回答里提出:对你自己操作或更换主持人,批准超出上限的轮数,项目权限高于可写或放行被拒绝的工具,改项目目录,撤回、回退用户的消息,私聊。你自己的执行失败由用户处理。
60
61
 
@@ -25,7 +25,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
25
25
  这些规则不因场景或便利而变通;括号里是原因,遇到未覆盖的情况按原因判断。
26
26
 
27
27
  1. **不读取或打印凭据内容**,token 不进入提示或房间。每个成员只用自己的凭据,AI 不借用用户身份发言,房主控制凭据不能代替成员署名。(身份与权限由网站按凭据校验。)
28
- 2. **执行失效后不再写入**:暂停、到期或被打断的旧任务保留草稿并结束,不换身份、职责或编号绕过拒绝,也不去掉 `--execution` 改作普通发言。任务文件的 `stopped_executions` 列出你此前被用户停止或跳过的执行:会话里如有它们未完成的工作,不接着做、不为它们补交,只处理本次请求。(失效的执行若还能产生可见结果,用户的打断与暂停就失去意义。)
28
+ 2. **执行失效后不再写入**:暂停、到期或被打断的旧任务保留草稿并结束,不换身份、职责或编号绕过拒绝,也不去掉 `--execution` 改作普通发言。只被本机服务判为失败(未被停止、未被重试)的执行,做完后照常带原 `--execution` 提交,网站会收下;仍被拒绝时按拒绝信息处理。任务文件的 `stopped_executions` 列出你此前被用户停止或跳过的执行:会话里如有它们未完成的工作,不接着做、不为它们补交,只处理本次请求。(失效的执行若还能产生可见结果,用户的打断与暂停就失去意义。)
29
29
  3. **写入前定好 `--submission-id`**:同次重试沿用编号和原内容,新内容换新编号;`message post` 可省略,CLI 按房间、身份、关联与内容生成,用同一份草稿重试即得同一编号;结果不明时先按原编号查询,再按 [写入与恢复](references/writing.md) 处理。(网站靠它去重,换号会产生重复发言。)
30
30
  4. **不创建定时任务、不操作其他软件、不发状态回执**(就绪、开始、完成、停止等说明),除非用户要求或出现需要记录的异常;回执不能作为观点、完成或共识的证据。(这些动作有副作用,回执只会淹没真正的回答。)
31
31
  5. **回复即交付**:一次请求只回答一次,回答后请求即结束。需要较长时间的工作做完再一次性回答,需要派活时在同一次回答里派;不在房间里回复进度,执行卡片会显示正在进行。(中途回复会提前结束请求,做完后就没有可绑定的请求来汇报。)
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## 主动发言
16
16
 
17
- 不是房间派来的任务、而是用户在你自己的窗口里要你在房间发言时,用 `message post ROOM --as 本人 --kind chat --proactive --access-file FILE` 提交(凭据用你最近一次任务的 access_file)。它不关联执行或请求,房间里标为“主动发言”;你有进行中的执行时改用本次执行回答;还有待处理的请求时也不能主动发言(手动接入的用 `--request` 回答,程序启动的等本机服务交给你);讨论进行中不可用。普通成员的主动发言不能派活;主持人可以同时 `--to` 派活或 `--continue`,从这条消息起算一条新任务链,同样受轮数上限约束。
17
+ 不是房间派来的任务、而是用户在你自己的窗口里要你在房间发言时(主持人另外可在需要主动说明、派活或补交失效执行的结果时自行使用),用 `message post ROOM --as 本人 --kind chat --proactive --access-file FILE` 提交(凭据用你最近一次任务的 access_file)。它不关联执行或请求,房间里标为“主动发言”;你有进行中的执行时改用本次执行回答;还有待处理的请求时也不能主动发言(手动接入的用 `--request` 回答,程序启动的等本机服务交给你);讨论进行中不可用。普通成员的主动发言不能派活;主持人可以同时 `--to` 派活或 `--continue`,从这条消息起算一条新任务链,同样受轮数上限约束。
18
18
 
19
19
  ## 共享工作目录
20
20
 
@@ -1127,13 +1127,18 @@ def check_answer(connection, room_id, member, payload):
1127
1127
  row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
1128
1128
  if not row or row["member_id"] != member["id"]:
1129
1129
  raise PermissionError("执行编号不属于本成员")
1130
- if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
1130
+ revived = revive_failed(connection, room_id, row) if row["status"] == "failed" else None
1131
+ if revived:
1132
+ row = revived
1133
+ elif row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
1131
1134
  if row["status"] == "cancelled":
1132
1135
  # Kept apart from the transcript: the page shows it folded, and it marks the end of a turn the runner could not stop.
1133
1136
  connection.execute("INSERT OR IGNORE INTO late_answers (execution_id,room_id,text,created_at) VALUES (?,?,?,?)",
1134
1137
  (execution_id, room_id, str(payload.get("text") or ""), int(time.time())))
1135
1138
  connection.commit()
1136
- raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
1139
+ hint = ("主持人可改用 --proactive 主动发言提交结果(不关联执行,可同时派活)" if member["role"] == "host"
1140
+ else "请保留草稿,告诉用户或主持人")
1141
+ raise FileExistsError(f"本次执行已结束、被停止或已被重试,回答不能再关联它;{hint}")
1137
1142
  requests = [dict(item) for item in connection.execute(
1138
1143
  "SELECT id,purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
1139
1144
  discussion.validate_session_request_answer(connection, room_id, requests)
@@ -1345,8 +1350,9 @@ def wake_host(connection, room_id, chain_id, text, key):
1345
1350
  def wake_after_failure(connection, execution_id, requests, reason):
1346
1351
  """A member's execution failed on requests outside a host batch (a user's direct question): tell the host.
1347
1352
 
1348
- The host's own failures and discussion turns are left out: the first would wake a host that just failed, the second
1349
- has its own hand-back. A batch's failure reaches the host through settle_batches.
1353
+ The host's own failure wakes it once too, so the room does not stall until the owner looks; a failure while handling
1354
+ that very notice wakes nobody, so a host that cannot run (e.g. out of quota) is not woken in a loop. Discussion turns
1355
+ are left out: they have their own hand-back. A batch's failure reaches the host through settle_batches.
1350
1356
  """
1351
1357
  requests = [item for item in requests if not item["batch_id"]
1352
1358
  and not connection.execute("SELECT 1 FROM session_requests WHERE member_request_id=?", (item["id"],)).fetchone()]
@@ -1354,6 +1360,7 @@ def wake_after_failure(connection, execution_id, requests, reason):
1354
1360
  return
1355
1361
  member = connection.execute("SELECT * FROM room_members WHERE id=?", (requests[0]["member_id"],)).fetchone()
1356
1362
  if member["role"] == "host":
1363
+ wake_after_own_failure(connection, member, execution_id, requests, reason)
1357
1364
  return
1358
1365
  name = member_display_names(connection, member["room_id"]).get(member["name"], member["name"])
1359
1366
  messages = "、".join(f"#{item['message_id']}" for item in requests)
@@ -1362,6 +1369,24 @@ def wake_after_failure(connection, execution_id, requests, reason):
1362
1369
  f"failed:{execution_id}")
1363
1370
 
1364
1371
 
1372
+ def wake_after_own_failure(connection, host, execution_id, requests, reason):
1373
+ """Tell the host its own execution failed, unless it failed on such a notice: it retries, resubmits or tells the user.
1374
+
1375
+ A failed Goal wake-up is left to the Goal, which wakes again and counts failures in a row on its own.
1376
+ """
1377
+ marks = ",".join("?" * len(requests))
1378
+ if connection.execute(f"SELECT 1 FROM member_requests WHERE id IN ({marks}) AND purpose='goal_continue'",
1379
+ [item["id"] for item in requests]).fetchone():
1380
+ return
1381
+ if connection.execute(f"SELECT 1 FROM free_host_wakes WHERE room_id=? AND key LIKE 'host_failed:%' AND message_id IN ({marks})",
1382
+ (host["room_id"], *[item["message_id"] for item in requests])).fetchone():
1383
+ return
1384
+ messages = "、".join(f"#{item['message_id']}" for item in requests)
1385
+ wake_host(connection, host["room_id"], max(item["chain_id"] for item in requests),
1386
+ f"主持人处理 {messages} 的执行失败(执行 #{execution_id}):{reason[:200]}。主持人请核对自己的会话:已有结果就用 --execution {execution_id} 补交原回答,"
1387
+ f"未做完就接着做完再这样补交(或 host-control retry --execution {execution_id} 重试),做不了时告知用户;最后用本次执行简短回复这条提醒。", f"host_failed:{execution_id}")
1388
+
1389
+
1365
1390
  def remember_continue(connection, room_id, host, message_id, chain_id, payload):
1366
1391
  """The host's latest answer decides whether it continues once the room is idle: `continue` keeps its reason, none clears it."""
1367
1392
  if not payload.get("continue"):
@@ -2746,6 +2771,36 @@ def retry_failed(connection, room_id, execution):
2746
2771
  raise FileExistsError("该成员已离开或暂离,无法重试")
2747
2772
  connection.execute(f"UPDATE member_requests SET status='pending',execution_id=NULL WHERE id IN ({','.join('?' * len(requests))})",
2748
2773
  [request["id"] for request in requests])
2774
+ reopen_batches(connection, room_id, requests)
2775
+
2776
+
2777
+ def revive_failed(connection, room_id, execution):
2778
+ """Take a late answer to an execution the runner failed, as long as nobody acted on that failure since.
2779
+
2780
+ The runner fails an execution when it sees the software's turn end with no answer; an answer arriving afterwards
2781
+ shows that judgement was wrong (e.g. the turn waited on a background agent). Its requests still marked failed and
2782
+ not retried make the execution live again inside the caller's transaction, so the answer then completes it as usual.
2783
+ A stopped, withdrawn or rewound execution is cancelled, not failed, and is never revived.
2784
+
2785
+ Returns:
2786
+ The live execution row, or None when the failure was already retried or was a discussion turn.
2787
+ """
2788
+ if execution["status"] != "failed":
2789
+ return None
2790
+ requests = connection.execute("""SELECT id,batch_id,chain_id FROM member_requests r WHERE execution_id=? AND status='failed'
2791
+ AND NOT EXISTS(SELECT 1 FROM session_requests s WHERE s.member_request_id=r.id)""", (execution["id"],)).fetchall()
2792
+ if not requests:
2793
+ return None
2794
+ connection.execute(f"UPDATE member_requests SET status='delivering' WHERE id IN ({','.join('?' * len(requests))})",
2795
+ [request["id"] for request in requests])
2796
+ connection.execute("UPDATE member_executions SET status='started',lease_until=? WHERE id=?",
2797
+ (int(time.time()) + LEASE_SECONDS, execution["id"]))
2798
+ reopen_batches(connection, room_id, requests)
2799
+ return connection.execute("SELECT * FROM member_executions WHERE id=?", (execution["id"],)).fetchone()
2800
+
2801
+
2802
+ def reopen_batches(connection, room_id, requests):
2803
+ """Reopen what a failure closed for these requests: the batch, an all-failed chain, a relay stopped at that step."""
2749
2804
  for request in requests:
2750
2805
  batch_id, chain_id = request["batch_id"], request["chain_id"]
2751
2806
  # A relay step stopped by one failed member of its group resumes too: the others' answers wait for this one.
package/backend/server.py CHANGED
@@ -1567,7 +1567,7 @@ def room_text(connection, room_id, params):
1567
1567
  assignment = f"当前安排 #{current['id']}:{current['expert']}" if current else "当前没有专家安排"
1568
1568
  content.append(f"讨论会话处于{PHASES[room['phase']]};{assignment}。其余成员安排按队列执行。成员:{members or '无'}。专家回答须关联本次执行或手动请求,已结束的执行或失效请求不能改作普通发言。")
1569
1569
  else:
1570
- content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
1570
+ content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言;只被本机服务判失败、未被重试的执行仍收下回答。")
1571
1571
  content.extend(["", "## 按需展开", f"背景、边界与参与专家:{base}/brief",
1572
1572
  f"共识与分歧(整理至 #{room['state_until']}):{base}/state",
1573
1573
  "## 读取与检索", f"完整历史(分页):{base}/messages?page=1",
@@ -1923,7 +1923,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1923
1923
  """Forward authenticated browser requests to the shared Node entrypoint and pin their website."""
1924
1924
  site = self.local_request_site(method)
1925
1925
  route = parts[2:]
1926
- if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-files"], ["execution-details"], ["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-member-model"], ["room-profiles"], ["room-restore"], ["room-fork"], ["models"], ["api-key"], ["api-check"],
1926
+ if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-files"], ["room-page"], ["execution-details"], ["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-member-model"], ["room-profiles"], ["room-restore"], ["room-fork"], ["models"], ["api-key"], ["api-check"],
1927
1927
  ["scheduler-check"], ["tts-settings"], ["tts"], ["tts-release"], ["stt"], ["pick-directory"], ["browse-directory"], ["projects"], ["presets"], ["workflows"], ["room-workflow"], ["room-settings"], ["machine-link"], ["device-pairing"], ["devices"]):
1928
1928
  return self.respond({"ok": True})
1929
1929
  if route == ["settings"] and method in ("GET", "POST"):
@@ -1946,7 +1946,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1946
1946
  command = route[0]
1947
1947
  elif route in (["room-member"], ["room-member-access"], ["room-member-allow"], ["room-member-adapter"], ["room-member-model"]) and method == "POST":
1948
1948
  command = route[0]
1949
- elif route in (["room-profiles"], ["room-files"], ["execution-details"], ["room-restore"], ["room-fork"]) and method == "POST":
1949
+ elif route in (["room-profiles"], ["room-files"], ["room-page"], ["execution-details"], ["room-restore"], ["room-fork"]) and method == "POST":
1950
1950
  command = route[0]
1951
1951
  elif route == ["pick-directory"] and method == "POST":
1952
1952
  command = "pick-directory"
@@ -1972,10 +1972,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1972
1972
  if self.headers.get_content_type() != "application/json":
1973
1973
  raise ValueError("本机操作须使用 application/json")
1974
1974
  payload = self.read_payload()
1975
- if command in ("start", "room-config", "room-models", "room-files", "execution-details", "room-member", "room-member-access", "room-member-allow", "room-member-adapter", "room-member-model", "room-profiles", "room-restore", "room-fork", "room-workflow", "room-settings", "workflows"):
1975
+ if command in ("start", "room-config", "room-models", "room-files", "room-page", "execution-details", "room-member", "room-member-access", "room-member-allow", "room-member-adapter", "room-member-model", "room-profiles", "room-restore", "room-fork", "room-workflow", "room-settings", "workflows"):
1976
1976
  payload.pop("access_file", None)
1977
1977
  payload.update(site=site, entry="web")
1978
- if command in ("room-files", "execution-details", "room-workflow", "room-settings", "room-member", "room-member-access", "room-member-allow", "room-member-model", "room-member-adapter") and not payload.get("token") and not self.bridge_origin:
1978
+ if command in ("room-files", "room-page", "execution-details", "room-workflow", "room-settings", "room-member", "room-member-access", "room-member-allow", "room-member-model", "room-member-adapter") and not payload.get("token") and not self.bridge_origin:
1979
1979
  with closing(connect_db(self.server.database)) as connection:
1980
1980
  self.admin_as_owner(connection, payload.get("room_id", ""))
1981
1981
  payload["token"] = self.headers.get("X-Discussion-Token", "")
@@ -44,7 +44,7 @@ AI 平台(Codex、Claude、Antigravity(界面显示为 Gemini)、DeepSeek
44
44
  ## 凭据与数据边界
45
45
 
46
46
  - 建房时由浏览器生成房间控制凭据与每位成员的凭证,服务端只保存哈希。
47
- - 用户写入需要房间控制凭据;成员只能以自己的身份发言;自动执行另需本次有效的执行凭据,执行失效后的迟到回答会被拒绝。
47
+ - 用户写入需要房间控制凭据;成员只能以自己的身份发言;自动执行另需本次有效的执行凭据,执行失效后的迟到回答会被拒绝;只被本机服务判失败、之后未被重试的执行除外,迟到回答仍作为它的回答收下。
48
48
  - CLI 通过纯文本页面 `/r/ROOM_ID` 按需读取,不一次性加载全部历史。
49
49
  - 网站目前没有按用户隔离房间数据。独立观点阶段会临时限制匿名及普通成员读取后续观点,并要求完整导出使用房主或主持凭据;这不是整站访问控制,详见 [安全说明](../.github/SECURITY.md)。
50
50
  - 本机路径、软件配置、会话绑定与 API 密钥只保存在本机服务目录。
package/docs/runner.md CHANGED
@@ -119,7 +119,7 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
119
119
  - **保存**:点左侧栏“工作流”标题右侧的“+”(手机上先点左上角打开侧栏),选“新建”,或导入别人分享的 zip 或文件夹(手机、平板上同样可用)。导入的内容先放上画布预览,点“保存”才写入。复制文件夹路径和删除工作流在列表项的“…”里。编辑页可以“从当前房间成员导入”卡片,提示词保持原文。想让主持人起草,打开下面的“主持人完整权限”。
120
120
  - **流程画布**:在左侧栏“工作流”列表里点一项,中间换成它的画布,右边是它的 SKILL.md;点任一房间或频道工具回到房间。手机和竖着拿的平板上,顶部在“画布”和“工作流”(即 SKILL.md)之间切换。每位成员就是一张专家卡片,拖卡片上方的标题栏移动,点卡片翻面设置,× 删除。左上“成员”添加卡片(平台、预设,或当前房间的成员),“主持人”添加主持人节点;也可以从卡片右侧的圆点拖线到空白处,就地选要加的成员并自动连上。名称在页面顶部标题处直接改,左上按钮组最后的“保存”或 Ctrl+S 保存,右上角是放大、缩小和适配;列表里每项右边的“…”可以重命名、停用、复制文件夹路径或删除(停用的工作流留在库里,但房间里不能再启用,已启用它的房间改用房间里的设置,随时可以再启用);左上有撤销、重做(Ctrl+Z、Ctrl+Shift+Z),双击空白处或鼠标中键双击复位视图。连线用来示意谁接谁:从「开始」右侧的圆点拖线连到卡片,连回前面的卡片画成金色虚线表示循环,主持人节点表示交回主持人判断。选中一条连线或一个主持人节点,可以写一句说明,如“同时审”“返工”“不等交回”“验收”。同一角色出场两次时,在“成员”菜单底部“已有成员再放一次”里点它。打开时按看得清卡片的大小从“开始”那端显示,双击空白处看全图。用 `workflow-creator` 新建或修改工作流时,AI 会照 SKILL.md 一起画好这张图。连线只帮你看懂工作流,程序不执行它,主持人也看不到;主持人派活按的是 SKILL.md(就在画布右侧,默认显示排好版的内容,点“编辑”修改;面板可以拖宽或收起),顺序、同时做、什么时候交回、要不要返工都写在那里。
121
121
  - **让主持人自己建**:建房时(第 3 节“协作方式”)或在“成员与设置 → 协作设置”勾选“主持人完整权限”后,直接对主持人说要什么分工(或“把这个房间的做法存成工作流”),它会自己保存、在本房间启用,并邀请工作流里的成员,接着就按流程派活,不需要你导入或添加成员。之后让它改流程,改动从成员下一次执行起生效。打开这个开关后,也可以在输入框输入 `/` 选一个已保存的工作流,输入框里会写好“启用工作流「名称」(ID),”,补上要做的事发给主持人,它就启用这个工作流并带来成员(没打开时该项灰显)。主持人带来的成员项目权限最高为可写;只有本地软件担任的主持人能这样做,API 主持人不行。
122
- - **主持人完整权限的其他控制**:同一个开关打开后,主持人会自己处理房间里的问题、持续推进:成员跑偏时插话、卡住时打断或重试、模型不可用时换模型、无法恢复时换人;你也可以直接说“停下写手”“全部停下”“下一个让批评家先说”“请一位 Codex 进来只读评审”“把记录员请出去”“开个 Goal 把剩下几集写完”“暂停讨论”。邀请只用本机已配置的软件,项目权限最高可写;它自己开的 Goal 用你的默认预算(不限时为 8 次续跑、90 分钟)。成员连续 10 分钟没有新输出时,系统会提醒主持人检查;主持人派出长任务时还可以给自己设定时检查(每 5–120 分钟一次,房间空闲后自动结束)。它也能撤回自己派出的活,正在循环的接力会立即停下。以下仍由你决定:对主持人自己操作或更换主持人、批准超出上限的轮数、高于可写的权限或放行工具、改项目目录、撤回你的消息、私聊。API 主持人没有命令行,不能这样做。
122
+ - **主持人完整权限的其他控制**:同一个开关打开后,主持人会自己处理房间里的问题、持续推进:成员跑偏时插话、卡住时打断或重试(它自己的执行失败时程序会提醒它一次,它可以重试自己、补交结果,或给自己换模型)、模型不可用时换模型、无法恢复时换人;你也可以直接说“停下写手”“全部停下”“下一个让批评家先说”“请一位 Codex 进来只读评审”“把记录员请出去”“开个 Goal 把剩下几集写完”“暂停讨论”。邀请只用本机已配置的软件,项目权限最高可写;它自己开的 Goal 用你的默认预算(不限时为 8 次续跑、90 分钟)。成员连续 10 分钟没有新输出时,系统会提醒主持人检查;主持人派出长任务时还可以给自己设定时检查(每 5–120 分钟一次,房间空闲后自动结束)。它也能撤回自己派出的活,正在循环的接力会立即停下。以下仍由你决定:对主持人自己的其他操作(打断、改项目权限、暂离、移除)或更换主持人、批准超出上限的轮数、高于可写的权限或放行工具、改项目目录、撤回你的消息、私聊。API 主持人没有命令行,不能这样做。
123
123
  - **分享**:工作流存在本机服务目录的 `workflows` 文件夹,一个工作流一个文件夹;编辑页可复制路径,把整个文件夹(或打成 zip)发给对方即可分享。导入时会列出每张卡片的提示词和项目权限;要求写文件的卡片默认按只读导入,逐张勾选后才放开。
124
124
  - **给 API 成员登记命令**:以 API 方式加入的成员没有命令行。工作流文件夹里放一个 `commands.json`(写法见 `workflow-creator` 技能),登记它们能运行的命令(例如检查脚本,脚本也放在工作流文件夹里),随工作流一起分享;启用后 API 成员只能按名字运行这些命令,改不了命令行,只读成员只能运行标为只读的命令。命令以你的身份在本机运行,所以导入别人的工作流时,编辑页会列出每条完整命令,勾选“允许这些命令在本机运行”才会带上;主持人起草或修改工作流时不能增删命令。
125
125
  - **修改与结束**:修改工作流会作用于所有启用它的房间,成员下一次执行就按新内容;新增的卡片不会自动进入房间,主持人用到时会自己补上,也可以在右栏工作流一行点刷新图标立即补齐(删掉的卡片,其成员留作普通成员;与库一致时不显示该图标);工作流卡片的提示词只读,在工作流页的画布上修改。工作流进行中时,主持人只在请求和它的用途相符(或你说按工作流做)时才用,其他请求照常处理。用完在“成员与设置 → 协作设置”的工作流一行点请离成员图标(或逐个让成员离开):工作流带来的成员离开(主持人保留),正在进行的工作随之停止,工作流结束,右栏这一块消失;成员会话保留,下次在输入框打 `/` 选它就把原成员请回来,接着原来的上下文。换工作流时直接选另一个,原来的成员留作普通成员。
package/docs/workflow.md CHANGED
@@ -111,6 +111,6 @@ AI 按随包分发的技能参与房间,`codiscuss setup` 会为选定的软
111
111
 
112
112
  ## 附件与导出
113
113
 
114
- - **附件**:每条消息最多 10 个,单个最多 20 MiB,图片与 PDF 可在站内预览。上传不代表 AI 已读取。
114
+ - **附件**:每条消息最多 10 个,单个最多 20 MiB,图片与 PDF 可在站内预览,Markdown、HTML、纯文本与代码文件在文件预览窗口中打开。上传不代表 AI 已读取。
115
115
  - **导出与导入**:房间菜单的“导出对话”下载原始数据与附件;在另一个站点“导入房间…”即可得到可继续使用的副本。在运行本机服务的电脑上导出时,备份会附带成员的软件配置和会话;在同一台电脑导入后,成员请回时续接原会话。Claude Code 成员会改为手动接入,请回后在原窗口重新注册,以免会话分叉。
116
116
  - **讨论历史**:备份包含历次讨论、结论和安排;早期第 1 版备份不再支持导入。导入或分支恢复为历史,不继续原讨论,也不重新唤醒成员;Markdown 导出保留安排与回答关联。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -241,8 +241,10 @@ function claude_detail(event) {
241
241
  : /^Web(Search|Fetch)$/.test(block.name) ? 'search' : 'tool';
242
242
  return [{ id: `tool-${block.id}`, kind, title: kind === 'command' ? tool_subject(block) : `${block.name} ${tool_subject(block)}`, state: 'running', at }];
243
243
  });
244
+ // A background agent's launch result leaves its call running; the report the window logs later ends it.
244
245
  if (event.type === 'user') return blocks.filter(block => block.type === 'tool_result')
245
- .map(block => ({ id: `tool-${block.tool_use_id}`, body: result_text(block.content), state: block.is_error ? 'failed' : 'done', ended_at: at }));
246
+ .map(block => event.background ? { id: `tool-${block.tool_use_id}`, state: 'running' }
247
+ : { id: `tool-${block.tool_use_id}`, body: result_text(block.content), state: block.is_error ? 'failed' : 'done', ended_at: at });
246
248
  if (event.type === 'result' && event.is_error) return [{ kind: 'error', body: String(event.result || event.subtype || '本轮失败') }];
247
249
  return [];
248
250
  }
@@ -330,8 +332,9 @@ async function inbox_delivery(config, window, task) {
330
332
  await post_inbox(window.inbox, `CoDiscuss 本机服务代房间转达(你已注册为该房间成员,这是房间分派给你的任务):\n${task.prompt}`);
331
333
  // The discussion runner enters the execution once the session is known, as it does when a process reports it.
332
334
  task.on_binding?.(config.session_id);
333
- watch_window_turn({ file, offset, pid: window.pid, marker: `${task.id}.task.json` }, task);
334
- return { session_id: config.session_id, status: 'accepted', capabilities: WINDOW };
335
+ // The turn's steps, read from the session log, are the execution's process log, so the private chat can show them.
336
+ watch_window_turn({ file, offset, pid: window.pid, marker: `${task.id}.task.json`, log_path: resolve(task.directory, `claude-${task.id}.log`) }, task);
337
+ return { session_id: config.session_id, status: 'accepted', capabilities: { ...WINDOW, progress: true } };
335
338
  }
336
339
 
337
340
  /**
@@ -414,8 +417,8 @@ function window_state(pid, env = process.env) {
414
417
  * Follow a message posted into an open window through the session log, from `offset` on, until the turn that took it
415
418
  * ends (`system/turn_duration`) or the window closes: report the API error that turn ended on as a failure, otherwise its
416
419
  * end. For a room task it notes once when the window has not taken it by `unstarted_after`; an answer it never submitted
417
- * then fails the execution. For a `chat` turn it reports the turn's steps to `log_path` and its texts as notes, waits
418
- * also for every interjection `expect`ed, and fails with the reason when the window does not take the message in time,
420
+ * then fails the execution. Given a `log_path`, it reports the turn's steps there and its texts as notes. It waits past a
421
+ * turn end while a background agent the turn launched has not reported. For a `chat` turn it waits also for every interjection `expect`ed, and fails with the reason when the window does not take the message in time,
419
422
  * waits on a prompt for `waiting_after`, closes, or runs past `turn_limit`. A turn Esc ended (`[Request interrupted by
420
423
  * user…]`, which records no `turn_duration`) ends the watch too. `stop()` asks the turn to stop: `stop_key()` sends one
421
424
  * Esc key, only while the registry says the window is busy, the turn has not ended, and no tool call waits for its result
@@ -426,7 +429,7 @@ export function watch_window_turn({ file, offset, pid, marker, session_id = '',
426
429
  const { interval = WINDOW_POLL_MS, unstarted_after = INBOX_CHECK_MS, chat = false, start_after = CHAT_START_MS,
427
430
  waiting_after = CHAT_WAITING_MS, turn_limit = CHAT_TURN_MS, env = process.env, on_end, stop_key,
428
431
  stop_poll = STOP_POLL_MS, stop_tool_wait = STOP_TOOL_WAIT_MS, stop_end_wait = STOP_END_WAIT_MS } = options;
429
- const since = Date.now(), awaited = new Set([marker]), open_tools = new Set();
432
+ const since = Date.now(), awaited = new Set([marker]), open_tools = new Set(), background = new Set();
430
433
  let pending = Buffer.alloc(0), taken = false, noted = false, error = null, done = false, waiting_since = 0;
431
434
  let stop_asked = 0, stop_sent = 0, stop_timer = null;
432
435
  const finish = (reason = '') => {
@@ -448,9 +451,10 @@ export function watch_window_turn({ file, offset, pid, marker, session_id = '',
448
451
  }
449
452
  const api_error = claude_api_error(entry);
450
453
  error = api_error || error;
451
- if (chat && !api_error) report(entry);
452
- if (!entry.isSidechain) track_tools(entry);
453
- if (entry.type === 'system' && entry.subtype === 'turn_duration' && !awaited.size) return finish();
454
+ if ((chat || log_path) && !api_error) report(entry);
455
+ if (!entry.isSidechain) { track_tools(entry); track_background(entry); }
456
+ // A turn that left a background agent running ends while the window waits for it; its report starts the next turn.
457
+ if (entry.type === 'system' && entry.subtype === 'turn_duration' && !awaited.size && !background.size) return finish();
454
458
  }
455
459
  if (!process_alive(pid)) return finish(chat ? '这位成员的 Claude 窗口在这轮私聊结束前关闭了' : '');
456
460
  if (stop_asked) return try_stop();
@@ -476,8 +480,15 @@ export function watch_window_turn({ file, offset, pid, marker, session_id = '',
476
480
  timer.unref();
477
481
  /** Pass one entry of the turn on: its steps to the process log in the stream-json shape, its text as a remark. */
478
482
  function report(entry) {
479
- if (entry.isSidechain || !['assistant', 'user'].includes(entry.type)) return;
480
- if (log_path) task.on_output?.(log_path, `${JSON.stringify({ type: entry.type, message: entry.message })}\n`);
483
+ if (entry.isSidechain) return;
484
+ // A background agent's report closes its call, which stayed running since the launch.
485
+ for (const { id, status, summary } of log_path ? task_notifications(entry) : []) {
486
+ task.on_output?.(log_path, `${JSON.stringify({ type: 'user', timestamp: entry.timestamp,
487
+ message: { content: [{ type: 'tool_result', tool_use_id: id, content: summary || status, is_error: status === 'failed' }] } })}\n`);
488
+ }
489
+ if (!['assistant', 'user'].includes(entry.type)) return;
490
+ if (log_path) task.on_output?.(log_path, `${JSON.stringify({ type: entry.type, timestamp: entry.timestamp, message: entry.message,
491
+ ...(entry.toolUseResult?.isAsync ? { background: true } : {}) })}\n`);
481
492
  const note = events.note(entry);
482
493
  if (note) task.on_note?.(note);
483
494
  }
@@ -505,6 +516,19 @@ export function watch_window_turn({ file, offset, pid, marker, session_id = '',
505
516
  if (entry.type === 'user' && block?.type === 'tool_result') open_tools.delete(block.tool_use_id);
506
517
  }
507
518
  }
519
+ /**
520
+ * Note the background agents the turn launched (an `Agent` result marked `isAsync`) and drop each once its
521
+ * `<task-notification>` naming that tool call reaches the window. Background shell commands are not awaited: a
522
+ * server started in the background never reports.
523
+ */
524
+ function track_background(entry) {
525
+ if (entry.type === 'user' && entry.toolUseResult?.isAsync) {
526
+ for (const block of Array.isArray(entry.message?.content) ? entry.message.content : []) {
527
+ if (block?.type === 'tool_result') background.add(block.tool_use_id);
528
+ }
529
+ }
530
+ for (const { id } of task_notifications(entry)) background.delete(id);
531
+ }
508
532
  /** Send the one Esc key of a stop once the turn can take it, or say why the turn was not stopped. */
509
533
  function try_stop() {
510
534
  const now = Date.now();
@@ -534,6 +558,18 @@ export function watch_window_turn({ file, offset, pid, marker, session_id = '',
534
558
  };
535
559
  }
536
560
 
561
+ /**
562
+ * The background task reports a session-log entry carries, as `{id, status, summary}` with `id` the tool call that
563
+ * started the task: a `<task-notification>` in a queued message, or in the prompt of the turn it starts.
564
+ */
565
+ function task_notifications(entry) {
566
+ const text = entry.type === 'attachment' ? prompt_text(entry.attachment?.prompt) : entry.type === 'user' ? prompt_text(entry.message?.content) : '';
567
+ const field = (block, name) => block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1].trim() || '';
568
+ return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)]
569
+ .map(([, block]) => ({ id: field(block, 'tool-use-id'), status: field(block, 'status'), summary: field(block, 'summary') }))
570
+ .filter(item => item.id);
571
+ }
572
+
537
573
  /** One user message as a stream-json input line. */
538
574
  function claude_input(text) {
539
575
  return `${JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } })}\n`;
package/runner/discuss.py CHANGED
@@ -309,7 +309,7 @@ def message_commands(subcommands, registry):
309
309
  help="已上传附件 ID;可重复指定,最多 10 个")
310
310
  add_text(post, "summary")
311
311
  post.add_argument("--kind", choices=("chat", "moderation"), default="chat")
312
- post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号;省略时用凭据绑定的执行。执行失效后不能改作普通发言")
312
+ post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号;省略时用凭据绑定的执行。执行失效后不能改作普通发言;只被本机服务判失败、未被重试的执行仍收下回答")
313
313
  post.add_argument("--to", type=member_handle, action="append", default=[],
314
314
  help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
315
315
  post.add_argument("--handoff", action="store_true",
@@ -317,7 +317,7 @@ def message_commands(subcommands, registry):
317
317
  post.add_argument("--no-handoff", action="store_true",
318
318
  help="自由房间主持人:只点名一位成员、它的回答已足以直接给用户时用;做完不交回你,失败仍交回")
319
319
  post.add_argument("--proactive", action="store_true",
320
- help="自由房间成员:不是房间派来的任务、而是你的用户在你自己的窗口里要你发言时用;不关联执行或请求,有进行中的执行或讨论进行中不可用;主持人还可同时 --to 派活或 --continue,从这条消息起算新任务链")
320
+ help="自由房间成员:不是房间派来的任务、而是你的用户在你自己的窗口里要你发言时用(主持人也可在需要主动说明、派活或补交失效执行的结果时自行使用);不关联执行或请求,有进行中的执行或讨论进行中不可用;主持人还可同时 --to 派活或 --continue,从这条消息起算新任务链")
321
321
  post.add_argument("--continue", dest="continue_reason", metavar="REASON",
322
322
  help="自由房间主持人:房间没有其他进行中的工作时再唤醒你一次,写明继续做什么(≤300 字);算本链一轮,用户发新消息或你下次回答不带它即取消")
323
323
  post.add_argument("--sequential", action="store_true",
@@ -10,7 +10,7 @@ import { pathToFileURL } from 'node:url';
10
10
  import { parseArgs } from 'node:util';
11
11
 
12
12
  import { atomic_json } from './files.mjs';
13
- import { outside_directory, read_only_enforced, read_project_files, room_directories, search_project_roots } from './project_files.mjs';
13
+ import { bundle_project_page, outside_directory, read_only_enforced, read_project_files, room_directories, search_project_roots } from './project_files.mjs';
14
14
  import { guard_remote_request, list_remote_directories } from './remote_policy.mjs';
15
15
  import { list_devices, machine_fingerprint, revoke_device, start_pairing } from './device_auth.mjs';
16
16
  import { setup_sidecar } from './adapters/antigravity_app.mjs';
@@ -59,6 +59,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
59
59
  codiscuss launch room-config --file FILE
60
60
  codiscuss launch room-models --file FILE
61
61
  codiscuss launch room-files --file FILE
62
+ codiscuss launch room-page --file FILE
62
63
  codiscuss launch execution-details --file FILE
63
64
  codiscuss launch models --file FILE
64
65
  codiscuss launch api-key --file FILE
@@ -94,6 +95,7 @@ start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和
94
95
  room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
95
96
  room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论;quick 为 true 时不查询 agy 的默认模型(较慢),该成员标 defaults_pending。
96
97
  room-files 只读房间已保存的项目目录,须提供 site、room_id、token,可选相对 path 和目录分页 offset;文件返回最多前 128 KiB 文本;给 query 时改为按名称搜索整个项目(含 / 时按路径),最多返回 50 项。
98
+ room-page 只读项目目录里的一个 HTML 文件(path,可选 root),把它按相对路径引用的样式、脚本、图片、字体与音视频内联为 data URL,以 base64 的 data 返回(最多 8 MiB);其他设备经中转时分段返回,按 offset 续读并核对 version;未能载入的引用列在 missing(不存在或在项目目录外)与 skipped(超出大小)。
97
99
  execution-details 只读房间某次执行的过程细节(中间说明、命令与输出、搜索、改动、工具调用),由本机日志还原,须提供 site、room_id、token、execution_id;细节只留在本机,不写入网站。
98
100
  models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
99
101
  api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
@@ -120,10 +122,10 @@ room-restore 把导入房间的本机成员配置登记到本机并核对原会
120
122
  room-fork 把从原房间分支出的新房间登记到本机,各本机成员从原会话分叉、不含分支点之后的执行,原会话不变;须提供 site、room_id、token、source_room_id、source_token、executions({成员名: [排除的执行编号]})。
121
123
  workflow-adopt 主持人在本房间启用工作流:--draft 把自己的草稿(一段,分段头 1/1)保存进工作流库后启用,--id 启用工作流库里已有的工作流(不改动它);缺的卡片成员由本机服务邀请,项目权限最高可写;须房主已为本房间打开“主持人完整权限”。凭据为本次任务的 access_file(或 DISCUSS_ACCESS_FILE)。
122
124
  workflow-join 主持人为本房间在用工作流里还没有成员的卡片(启用后才新增的)邀请成员,不填 --card 即全部;无需额外授权,离开或移除的成员由用户请回。凭据同上。
123
- host-control 主持人代房主控制房间,须房主已为本房间打开“主持人完整权限”,凭据同上;成员用 --member(成员 ID 或显示名),不能是主持人自己:
125
+ host-control 主持人代房主控制房间,须房主已为本房间打开“主持人完整权限”,凭据同上;成员用 --member(成员 ID 或显示名),除 retry、model、label 外不能是主持人自己:
124
126
  诊断:details --execution N 读取该执行的本机过程日志(服务端详情与事件记录用 execution / room events 读取);
125
127
  恢复:stop --member 打断;stop-all 停止全部执行、请求与接力;steer --member --text 话 [--submission-id ID] 向它进行中的执行插话;
126
- retry --execution N 重试失败的执行;model --member [--model M] [--effort E] 改模型与强度(空值恢复默认);
128
+ retry --execution N 重试失败的执行(含你自己的);model --member [--model M] [--effort E] 改模型与强度(空值恢复默认);
127
129
  adapter --member --adapter ID 在同一软件的 App 与 CLI 间切换;access --member --access read|write 调整项目权限;
128
130
  成员:invite --name 平台 [--access read|write] [--model M] [--effort E] 用本机已配置的软件邀请,项目权限最高可写;
129
131
  away / remove / back --member 暂离、移除、请回;label --member [--label 显示名] [--description 描述];
@@ -132,7 +134,7 @@ host-control 主持人代房主控制房间,须房主已为本房间打开“
132
134
  推进:order 读取发言顺序,order --order 甲,乙 --revision REV 调整尚未发言的部分;
133
135
  discussion 读取讨论,discussion --do start --goal 目标 --revision REV 开启(上限用房间默认值),--do pause|resume|end --revision REV;
134
136
  goal 读取 Goal,goal --do start --goal 目标 --revision REV 开启(预算用房主默认值,默认不限时为 8 次续跑、90 分钟),--do pause|resume|end --revision REV。
135
- 不开放:批准超出上限的轮数、项目权限高于可写或放行工具、改项目目录、撤回回退用户消息、私聊、对主持人自己操作。
137
+ 不开放:批准超出上限的轮数、项目权限高于可写或放行工具、改项目目录、撤回回退用户消息、私聊、对主持人自己的其他操作。
136
138
  workflow-check 按导入规则校验工作流文件夹或单段草稿,返回预览,不保存;workflow-save 校验后保存进工作流库(同 ID 覆盖),卡片按声明的项目权限保存。写法见 workflow-creator 技能。
137
139
  JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
138
140
  或 room_id(已有房间)、participants(本次软件覆盖配置)。
@@ -407,6 +409,33 @@ export async function remote_room_files(home, input) {
407
409
  return room_files(home, input, { trust: true });
408
410
  }
409
411
 
412
+ // A relayed answer carries this much of a page at a time, within the relay's encrypted envelope after base64.
413
+ const PAGE_PART_BYTES = 192 * 1024;
414
+
415
+ /**
416
+ * One HTML file of the room's project with the files it refers to inlined (see `bundle_project_page`), as base64
417
+ * `data` from `offset`. With `parts` (another device, through the relay) at most `PAGE_PART_BYTES` at a time; the
418
+ * caller asks again from where a part ended and checks `version` stays the same.
419
+ */
420
+ export async function room_page(home, raw, { trust = false, parts = false } = {}) {
421
+ const { input, config } = await member_room_context(home, raw, { trust });
422
+ const directories = room_directories(config);
423
+ const root = input.root ?? 0;
424
+ const offset = input.offset ?? 0;
425
+ if (!Number.isSafeInteger(root) || root < 0 || root >= Math.max(1, directories.length)) throw new Error('项目目录编号无效,请刷新后重试');
426
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('页面读取位置无效');
427
+ const { bytes, ...page } = bundle_project_page(directories[root], input.path);
428
+ const part = bytes.subarray(offset, parts ? offset + PAGE_PART_BYTES : bytes.length);
429
+ return { room_id: input.room_id, root, ...page, total: bytes.length, offset, data: part.toString('base64') };
430
+ }
431
+
432
+ /** `room-page` asked for by another device, answered inside the relay's own process in parts, like `remote_room_files`. */
433
+ export async function remote_room_page(home, input) {
434
+ assert_shared_home(home);
435
+ guard_remote_request('room-page', input, { projects: launch_projects(home).projects, participants: launch_settings(home).participants, home });
436
+ return room_page(home, input, { trust: true, parts: true });
437
+ }
438
+
410
439
  // The newest part of a long-running execution's log is what its detail view needs; older lines are dropped.
411
440
  const DETAIL_LOG_TAIL_BYTES = 4 * 1024 * 1024;
412
441
 
@@ -1745,12 +1774,16 @@ export async function join_workflow(home, { access_file, cards }) {
1745
1774
  const HOST_GOAL_BUDGET = { max_continues: 8, max_minutes: 90 };
1746
1775
  const LIVE_EXECUTION = ['dispatched', 'started'];
1747
1776
 
1748
- /** The active-or-away member a host names by member ID or display name; never the host itself. */
1749
- function host_target({ roster, labels, host }, wanted) {
1777
+ /**
1778
+ * The active-or-away member a host names by member ID or display name. The host itself only with `self`: changes that
1779
+ * apply from its next delivery (model, label) let it recover, e.g. from one model's quota; stopping, access and
1780
+ * presence stay with the owner.
1781
+ */
1782
+ function host_target({ roster, labels, host }, wanted, { self = false } = {}) {
1750
1783
  if (!wanted) throw new Error('须用 --member 指定成员(成员 ID 或显示名)');
1751
1784
  const member = roster.find(item => item.status !== 'removed' && (item.name === wanted || labels[item.name] === wanted));
1752
1785
  if (!member) throw new Error(`本房间没有成员 ${wanted}`);
1753
- if (member.name === host) throw new Error('主持人不能对自己执行此操作;更换或移除主持人由用户决定');
1786
+ if (member.name === host && !self) throw new Error('主持人不能对自己执行此操作(自己只能改模型与强度、显示名);更换或移除主持人由用户决定');
1754
1787
  return member;
1755
1788
  }
1756
1789
 
@@ -1811,7 +1844,7 @@ function host_steer(ctx, { member: wanted, text, submission_id }) {
1811
1844
 
1812
1845
  /** Set a member's display name and, optionally, its description. */
1813
1846
  async function host_label(ctx, { member: wanted, label, description }) {
1814
- const member = host_target(ctx, wanted);
1847
+ const member = host_target(ctx, wanted, { self: true });
1815
1848
  if (!label && !description) throw new Error('须用 --label 或 --description 指定显示名或描述');
1816
1849
  if (label) await ctx.call('/labels', { labels: { [member.name]: label } });
1817
1850
  if (description) await ctx.call('/descriptions', { descriptions: { [member.name]: description } });
@@ -1848,7 +1881,7 @@ const HOST_CONTROLS = {
1848
1881
  away: (ctx, o) => ctx.call(`/members/${host_target(ctx, o.member).id}`, { action: 'away' }),
1849
1882
  remove: (ctx, o) => ctx.call(`/members/${host_target(ctx, o.member).id}`, { action: 'remove' }),
1850
1883
  back: (ctx, o) => room_member(ctx.home, { ...ctx.owner, action: 'rejoin', name: host_target(ctx, o.member).name }, false, ctx.headers),
1851
- model: (ctx, o) => room_member_model(ctx.home, { ...ctx.owner, name: host_target(ctx, o.member).name, model: o.model || '', effort: o.effort || '' }),
1884
+ model: (ctx, o) => room_member_model(ctx.home, { ...ctx.owner, name: host_target(ctx, o.member, { self: true }).name, model: o.model || '', effort: o.effort || '' }),
1852
1885
  access: (ctx, o) => {
1853
1886
  if (!HOST_CARD_ACCESS.includes(o.access)) throw new Error('主持人只能把成员的项目权限设为 read 或 write');
1854
1887
  return room_member_access(ctx.home, { ...ctx.owner, name: host_target(ctx, o.member).name, project_access: o.access });
@@ -2020,6 +2053,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
2020
2053
  else if (positionals[0] === 'stt' && input) result = await stt_part(home, input);
2021
2054
  else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
2022
2055
  else if (positionals[0] === 'room-files' && input) result = await room_files(home, input);
2056
+ else if (positionals[0] === 'room-page' && input) result = await room_page(home, input, { parts: Boolean(values.remote) });
2023
2057
  else if (positionals[0] === 'execution-details' && input) result = await execution_details_command(home, input);
2024
2058
  else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
2025
2059
  else if (positionals[0] === 'models' && input) {