# 桌面与信道 —— 谁听得见谁 > 2026-10-05。对应设计方案 §2.1(三条信道的图)与 §3.4 第 4 条(信息路由靠我自觉)。 ## 0. 一句话 **桌上所有人听得见所有人**——这句话在实现里曾经只是一个说法:AI 玩家的唯一通道是发给主持人, 其余人能不能听见,全靠主持人手打转述。 ## 1. 先把「玩家根本说不了话」修掉 §3.4 第 2 条让玩家**只能**说话了。但 2026-10-05 的实测里出现过一个更难发现的失败: **某位玩家一个字都没说出口,而外面看只是「她很安静」。** 她在会话日志里的原话: > **我实际发出的话为 0 句**——林小满这个角色至今没在桌上说过一个字。任何「p1 已就位/未发言」 > 的局面,成因在这里,不在角色。 **根因**:`playerBrief` 告诉玩家「发给主持人,`agent_id` 是 ``」。而 Team 的 `send_message` **按成员名字解析目标**——`agent-team/src/mailbox.ts:120`: ```ts const target = resolveActiveMember(root, state, request.target) ``` 找不到就抛 `active teammate "…" not found`(`roster.ts:52`)。而 `Agent.id` 是一个 `SessionId` (`core/agent/src/index.ts:212`),**不是成员名**。玩家拿那个 id 试了十次,十次全失败。 它试的第二条路(裸 UUID)被插件自己的闸拒了——那条拒得对。 **为什么难发现**:那次工具调用确实报了错,但**没有人会去翻一个安静玩家的日志**。从桌面上看, 「她这一轮没说话」和「她说了但没送到」长得一模一样。 **修法两条**: - `playerBrief` 只给 `lead`——唯一走得通的那个名字。 - 闸只放行 `lead`:`audience = new Set([LEAD_NAME])`。 第二条才是真正的判据:**放行一个走不通的名字,比拒绝它更坏。** 拒绝会当场报错;放行会让你 以为那条路是通的,然后把一个哑掉的座位当成一个安静的玩家。 ## 2. 桌面:一次说给全桌 `jubensha_player action="say"` 的 `seat` 现在也接受 `"*"`——桌上每个人都会收到, 一条命令发出去,程序保证一个都不漏。 **为什么不是「多调几次就行」**:逐个手发正是「漏掉某个人」的来源,而漏掉的那位不会知道自己 漏了什么,**桌上其他人却以为他听见了**。这两件事合起来,是一个不会被任何人报出来的错误。 `deliver()` 一个一个发,不等并发:`sendMessage` 对忙着的目标按步边界投递,并发发多条不会更快, 只会让「发到一半失败」时说不清谁收到了。发不出去就抛。 ## 3. 转达:把原话放回桌上 `action="relay", seat="p1"` 把 p1 刚说的话转给桌上其余人。 **它与「主持人复述一遍」的区别就是这一块存在的理由。** §3.4 第 4 条记着第一局的记忆漂移来自 手抄重建:主持人转述时用的是**自己的复述**,而复述会走样。relay 用的是**原件**——玩家发出去的 那条消息正文,由 `tools/result` 钩子当场记下: ```ts agent.ctx.on('tools/result', (exec, result) => { if (exec.name !== SPEAK_TOOL || result.isError) return const said = readMessage(exec.arguments) if (said !== undefined) players.recordSaid(seat, said) }) ``` `tools/result` 是纯观察钩子(`@mode emit`,按 `exec.agent` 过滤),监听器抛错会被内核隔离, 不会连累那次发言本身。 三个细节都是判据,不是修饰: - **只记成功的**(`result.isError` 时不记):发言没送到桌上,那它就还不算说过。 - **带署名**:转达时拼成 `(林小满)<原话>`。收信人的默认预期是「主持人在跟我说话」, 不加署名会把这句当成主持人说的。 - **下桌就忘**:`unseat` 连那句话一起删——否则它还能被转达给一桌新人。 ## 4. relay 仍要主持人调一次 > **2026-10-07 补**:这一节的权衡**已被实测推翻**——见 > [`2026-10-07-桌上说话-公共信道.md`](2026-10-07-桌上说话-公共信道.md)。 > 当时的理由是「防私聊被摊到桌上」,而实测里没有那样的私聊(真人这一侧只有桌面, > 见本节末尾),而转达本身在四处出错(署名不准、地址被摘、你可以接、转达要认话头)。 > **下面这一段保留原样,作为那个权衡的原文。** **这是「由主持人决定谁听见什么」,不是「自动广播」。** 选择是有意的:主持人刚跟某位玩家私聊过时,那位玩家的回复不该被自动摊到桌上。把决定权留给 主持人,代价是它得记得调 relay——而那比「自动广播把私聊内容漏出去」轻。**这是权衡,不是实测 结论**:私聊那一侧还没有实测过。 **还没做的**:人类玩家这一侧没有私聊信道。会话对话就是桌面,主持人对他说的私密话会出现在 所有人眼前。要解决它得先回答一个设计问题——真人玩家的「桌面发言」与「对主持人说话」怎么分, 而他现在也只有一个输入框。 ## 5. 实测 隔离实例 + 会话日志,驱动脚本 `jubensha-drive.mjs` 的 step 9。 ### 5.1 通道修好了 step 9 里 `relay` 的返回值是: ``` Error: 桌上只有 林小满 一个人,没有人可转达。 ``` **这条报错本身就是判据。** `relay` 的实现先查「这个人说过话没有」,再查「桌上有没有别人」—— 它停在第二道,说明第一道过了,也就是 `tools/result` 钩子**确实记下了林小满发出去的话**。 而上一轮,同一个位置的那个玩家一个字都发不出来(§1)。同一个动作、同一份 brief、同一台机器, 改前改后的两种结果。 ### 5.2 广播 `say seat="*"` 返回 `delivered: "林小满"`——广播那条路通了。但那时的桌上只有她一个人, **「一次说到多个人」这件事没有测到**。原因见下。 ### 5.3 没测到的:多人同桌被 Team 名册挡住了 同一步里 `spawn` p2 返回: ``` Error: Team member limit 8 reached ``` 查证结果(源码与配置,不是推断): - `agent-team/src/index.ts:41`:`DEFAULT_MAX_MEMBERS = 16` - 本部署的 8 来自 `packages/experimental/agent-team-profile/cordis.patch.yml:20`——**不是内核常量** - `agent-team/src/roster.ts:274-275`:成员数达上限时抛 `TEAM_MEMBER_LIMIT` - **成员不可移除**:`roster.ts:65` 的 JSDoc 写的是 "maximum **immutable** roster entries per Team", 而全库没有 `removeMember` / `dismiss` / `retire` 这类接口(grep 无命中) **这意味着**:一个 DM 会话里累计只能上 8 位 AI 玩家,而 `unseat` 只清插件自己的座位表, **不会**让名册腾出位子。按每局 3 位 AI 玩家算,**同一个会话开不了三局**。 **已定(2026-10-05 用户拍板):一局一换会话。** 另外两条不用走: - ~~把 `maxMembers` 配大~~——那是部署的配置,插件替不了使用者改; - ~~绕开 Team 直接用 `ctx.subagents.startContinuable`~~——它能解上限,但会丢掉已验证的 Team 实践(名册、成员名的解析、工具面拼装),为绕一个上限不值得。 **推论**:跨局的连续性**只能靠落盘的玩家档案**,不能靠会话上下文。这是「跨局记忆」那条工具 非做不可的理由——不是锦上添花。