liaowenqi123/dsh-meeting-coordinator

liaowenqi123/dsh-meeting-coordinator

给 DSH 会话装上会议室:加入会议室即同时获得召集权与参会义务,且一个会话只能属于一个会议室。召集时把每位参会者 fork 进场——带着它自己已完成的轮上下文,同时剥掉全部工具;主持人自身不带任何上下文,只负责挑下一位发言者;每条发言在说出的当下就落盘。散会后每人拿到一份只属于自己的纪要,作为一条用户输入送回工作区继续干活。

工作流与自动化agent-orchestrationai-agentscontext-isolation

dsh-meeting-coordinator

给一群长跑的 Agent 装上「会议室」与「互相唤起」。 基于 dsh-std 元协议开发,为 DeepSeek Harness 提供多 Agent 协作。

⚠️ 早期阶段 / Early stage. 核心机制已经跑通并有 215 个测试覆盖,但还没在真实生产场景里长期跑过。 遇到问题请开 Issue —— 非常欢迎反馈、提 bug、发 PR,见欢迎贡献。


一、精髓:开会,和互相唤起

单个 Agent 长时间工作时会得三种病:上下文污染、死循环(在自己的上下文里反复确认同一个错误结论)、 遗忘全局目标。多 Agent 并行的方案很多,但真正难的是"持续循环跑下去"。

本插件的答案不是更好的提示词,而是给 Agent 们一个组织。

1.1 一个新的概念:会议室(群)

关键区分——不是会话属于会议,而是会话加入会议室:

   会话 A ──┐                      ┌── 会话 B
            │   加入(主动)        │
            ├──▶  ┌──────────┐  ◀──┤
            │     │  会议室   │     │
   会话 C ──┘     └──────────┘     └── 会话 D(没加入)
                                                    │
                                          叫不动,也不会被误叫
  • 会议室是持久实体,不是每次开会临时建的;
  • 加入会议室 = 自动获得「唤起会议」的权利 + 「参会」的义务。 没加入的会话,既不能召集,也不会被召集;
  • 成员可跨工作区(成员列表里带 workspace,但不因此分裂成多个群);
  • 一个会话只能属于一个会议室。

这一条解决的是"不是每一个会话都是可被召集或唤起的": 会籍就是权限边界。你的私人助手会话不会因为某个 Agent 想开会就被拉进群聊。

1.2 「开会」是什么

不是"协调器把摘要广播出去"。是:

每个会话带着自己的记忆,临时进入会议室; 入场时预注入一段 prompt(说明议题、在场有谁、你可以说什么); 会议室是一个多轮群聊,由主持人控场,像真的开会一样互相回应; 会后每个会话各自对会议做压缩,只留下与自己相关的内容; 回去之后,记忆变成「原私有上下文 + 自己相关的会议纪要」。

关键在于每人拿到的纪要是不同的。如果是"一份全局会议纪要发给所有人", 那只是把广播换了个说法,别人的全部发言会原封不动挤进我的上下文——那正是污染。

  会话 A(记忆A)      会话 B(记忆B)      会话 C(记忆C)
        │                  │                  │
        └──────────┬───────┴───────┬──────────┘
                   ▼               ▼
        ┌────────────────────────────────────────────┐
        │  会议室(多轮群聊,共享 transcript)         │
        │                                            │
        │  入场:定位 prompt + 各自记忆的限长投影      │
        │  发言:主持人控场(主持人没有上下文)         │
        │  人类可随时插话(不占轮次)                  │
        └────────────────────────────────────────────┘
                   │               │
                   ▼               ▼
            A 的个性化纪要    B 的个性化纪要   C 的个性化纪要
              (内容各不相同)
                   │               │
                   ▼               ▼
            记忆A + 纪要A     记忆B + 纪要B   记忆C + 纪要C

1.3 「互相唤起」是什么

一个 agent 选择开会后,可以召集所有在工作或者不在工作的 agent (不在工作状态指的是输出完毕等待用户回复,或已完成任务), 开完会后,所有 agent 都会回到工作状态。

所以"唤起"是状态层面的动作,而且正在工作的会话不会被会议打断:

                      summon()
  working ─────▶ awaiting-entry ──(当前工作单元结束)──▶ in-meeting
  idle-waiting ────────────────────────────────────▶ in-meeting
  done ───────────────────────────────────────────▶ in-meeting

                      dismiss()  ──▶ 召集前的状态(还原,不是一刀切)
  • working(正在输出 / 正在调用工具)→ 先进 awaiting-entry。 不打断,等宿主在"本次输出结束 / 本次工具调用返回"的边界调 onWorkUnitComplete() 才入场。 这就是需求里说的"有一个等待的过程";
  • idle-waiting(等用户回复)和 done(任务已完成)没有工作单元要等,直接入场;
  • 散会时把每个人还原到被召集之前的状态:在跑的还在跑、等用户回复的继续等、 已完成的仍然完成,包括中途才入场的和始终没入场的(缺席)。 这解决了"会议开完了,那个已完成的 Agent 该干嘛"的问题。 ⚠️ 这里曾经写死成"全部推回 working",是一个会让会议室一次性报废的 bug: 从 idle-waiting 被叫来的成员散会后被谎报成"正在干活",下一次召集就永远等一个 不会到来的工作单元边界——"再点一次召集,就告诉我全员在工作中"。 详见 5.12。

1.4 主持人:有控场权,但没有上下文

设置一个主持人吧,模型使用唤起会话的模型,但是这个主持人直接没有上下文, 不会被任何人的上下文带偏。

这条把两件事彻底解耦了:

  • 控场(下一个谁说话、什么时候散会)由主持人负责;
  • 发言由各自会话产出,各自带着自己的记忆。

主持人只拿到群聊记录 + 成员名单 + 谁还没说话,拿不到任何人的私有记忆投影。 因此它不可能因为"某人的上下文更长/更详细"而偏好某人—— 这就是"不会被带偏"的机制保证,而不是一句提示词约定。

主持人看到的:  议题 / 群聊记录 / 名单 / 谁没说话 / 累计发言次数
主持人看不到:  任何人的工作记忆、代码、日志、历史会话

它用的是召集者会话的模型:谁开的会,就像谁在主持,风格一致, 且不需要为协调器单独引入一个模型依赖。

1.5 不做结构化

我觉得没必要做结构化的东西,AI 会自动讨论出合理结果的(这个主要还是针对 AI agent 的集会)

所以:

  • 入场 prompt 只做定位(你是谁、议题是什么、在场有谁、可以说什么), 不强制"进度/障碍/需要"三段式,也不强制任何字段;
  • 发言内容完全自由;
  • 唯一保留的硬约束是单次发言字数上限——它不是发言格式, 而是防上下文爆炸的工程底线(N 人 × R 轮不设限就会线性膨胀)。关键是软目标与硬上限分开:提示词只说"大致 800 字"(甚至明确写"不要去数字数、不要用工具核对字数"),硬上限放宽到 1600 字——紧贴实际长度的硬上限会逼模型去数字符甚至调工具核对,那是真实的算力黑洞。

主持人输出的控制决定是 JSON({"action":"invite","next":"..."} 或 {"action":"adjourn"})。 那不是给 AI 的模板,而是协调器与主持人之间的控制通道; 而且解析失败时绝不会卡住会议——会回退到安全轮转。

1.6 跑一遍看效果

pnpm run demo:room

真实输出(节选):

[1] 会议室是持久实体;会话主动加入才有"唤起/参会"的权利与义务
  ✓ 会议室「量化同步会」成员 3 人:
  · live-trading  工作区=quant-live  模型=deepseek-v4.1-flash
  · neural-net    工作区=quant-lab   模型=deepseek-v4-pro
  · trad-algo     工作区=quant-lab   模型=deepseek-v4-pro
  ✓ 成员来自 2 个不同工作区

[2] 不是所有会话都能被唤起:没加入的会话叫不动
  · data-infra 已登记为会话,但**没有加入**这个会议室
  ✓ 越权召集被拒绝:会话 data-infra 不是会议室 quant-sync 的成员,没有召集权。

[4] 人召集会议:working 的先进"待入场",空闲的直接入场
  · 会议已开始,入场情况:在场=human/neural-net/trad-algo  未到场=live-trading
  · live-trading 还在忙着,没有被打断 —— 它被记为"待入场"
  · → 现在 live-trading 的当前工作单元结束了(onWorkUnitComplete)
  · 会场更新:在场=human/live-trading/neural-net/trad-algo  未到场=(无)

[5] 入场引导:预注入 prompt + 自己的记忆投影,别人看不到
    【会议室入场】你是「实盘/泛化专家」(领域 live-trading)。
    本次会议议题:对齐成本约束与市场结构变化
    召集者:human;规模:大会(全体成员)。
    在场:neural-net、trad-algo、human(人类,有发言权)
    你被叫来开这个会。请自然地把别人不知道、但和你这个方向有关的情况说出来,比如:
    - 你现在做到哪了;- 你遇到了什么问题、卡在哪里;- 你需要谁的什么帮助…
    规则:
    - 这是一个多轮会议,由主持人控场决定发言顺序;你会在轮到你时被叫到。
    - 单次发言不超过 800 字(这不是格式要求,只是为了让所有人都有说话机会)。
    - 没有固定格式,怎么表达清楚就怎么来。
    你的私有记忆投影(只包含与你相关的部分;别人看不到这些):
    你最近的工作记录:
    - 逐笔滑点重算完成,taker 成本占 41% 夏普
    - 私有日志 9.8MB,未对外共享
  ✓ 引导里有定位信息与开放式提示,但**没有**强制的三段式模板

[6] 主持人控场:用召集者的模型,但自己没有任何上下文
    【会议主持】你是本次会议的主持人。
    你的信息范围(重要):
    - 你**没有任何与会者的私有上下文**:看不到他们的工作记忆、代码、日志、历史会话。
    - 你只能看到下面的群聊记录、成员名单,以及谁还没说话。
  ✓ 主持人只拿到群聊记录 + 名单 + 谁还没说话;拿不到任何人的私有记忆

[8] 每个参会者用的是自己的模型
  · 模型 deepseek-v4.1-flash ← live-trading
  · 模型 deepseek-v4-pro ← neural-net、trad-algo

[9] 会后压缩:每人各自提炼"与自己相关"的内容
  ── live-trading(65 字)
     待办:在 60 日 IC 窗口下重跑滑点估计并验证稳定性(我的责任)。会上确认换手压到
     5 倍以下后滑点约 20%,止损问题缓解。
  ── neural-net(73 字)
     待办:与 live-trading 对齐上线后的重训频率。会上确认换手降到 4.8 倍可行;
     若 60 日窗口上线,我的重训周期同步改为 60 日。
  ── trad-algo(59 字)
     待办:在 60 日窗口下重做统计显著性检验。我的判断被采纳:这是市场结构问题
     而非换手率问题;模型侧已承诺同步重训周期。

[11] 散会:所有成员回到工作状态
  · 散会后:data-infra=working,live-trading=working,neural-net=working,trad-algo=working

二、这个系统坚持的七个不变量

每一条都有对应的失败断言在测试里,不是口号。

不变量 1:会籍是权限边界

只有加入了会议室的会话才能召集、才会被召集。测试断言:

// 没加入的会话越权召集 → 被拒
await expect(orchestrator.convene({ calledBy: 'data-infra', ... })).rejects.toThrow(/没有召集权/)
// 排他会籍
expect(() => orchestrator.joinRoom('neural-net', 'other-room')).toThrow(RoomRegistryError)

会籍落盘(追加式 JSONL),进程重启后可恢复——内存态的群成员关系会在重启后静默消失, 让"唤起"变成无声失败。

不变量 2:私有上下文永不跨会话流动

进会议室时注入的是记忆的限长投影(memoryProjection(maxChars)),不是完整上下文:

expect(liveEntry.text).toContain('逐笔滑点重算完成')   // 自己的记忆
expect(liveEntry.text).not.toContain('残差宽度消融')   // 别人的私有记忆

为什么不共享上下文?因为共享上下文就是"上下文污染"本身。 Cognition 主张"别做多 Agent、上下文必须共享"——那是针对短周期、强耦合任务。 本项目面向长周期、弱耦合、方向可切分的场景,隔离才是解药。

不变量 3:长度是硬预算,超限拒绝而不是截断

expect(() => room.appendSpeech('a', '一'.repeat(801))).toThrow(/超长/)

静默截断比超长更糟:Agent 会以为自己的诉求已经传达了。

依据:调研发现"200 字"这个具体数字没有研究支持,是工程取舍。 但"硬上限 + 分字段预算"有先例(LangChain 的 max_token_limit 滚动摘要、 Caucus 把工具描述硬裁到 ≤260 字符)。所以本实现的是机制,上限是可配默认值。

不变量 4:数据根不依赖进程 cwd

expect(defaultMeetingRootDir(env)).not.toContain(process.cwd())

解析只有一处(core/meeting-root.ts),规则是 config.rootDir > DSH_MEETING_ROOT > $DSH_HOME/meeting-coordinator。

⚠️ 这条是踩出来的。原默认值是 join(process.cwd(), '.dsh-meeting'), 而 DSH 进程的 cwd 由"用户从哪个目录敲 dsh web"决定,与会话、工作区都无关。 2026-09-23 实测:重启后数据根落进了一个完全无关的项目目录, 会议室全部消失——插件照常启动、面板照常显示,一个错都不报。 用 cwd 的失败模式是"静默且看似成功",所以坐标必须选一个不依赖启动目录的地方。

不变量 5:急停是完整闭环,且不依赖房间

// 工具写出的路径 === 宿主 stopRequested 读的路径
expect(existsSync(join(configRoot, 'stop.flag'))).toBe(true)
// 且不需要 roomId
expect((await runMeetingTool({ args: { action: 'stop' }, ... })).ok).toBe(true)

这里曾经同时坏了两处,而且是互相掩护的:

  1. registerMeetingTool 根本没接 rootDir,自己又推算了一遍 —— 于是只要 config 里 给了 rootDir,工具写的标志文件和宿主读的不是同一个文件;
  2. roomId 校验排在 stop 分支之前 —— 于是 action=stop 永远走不到自己的分支, 一律返回"action=room / convene 需要 roomId"。

两者叠起来的表现是:调用 stop 得到 ok: true 和一条看似正确的路径, 而会议毫无反应。静默失效的急停比没有急停更糟——人会以为自己已经叫停了。 现在 rootDir 由宿主传入(同源),且 stop 不受任何房间前置条件约束。

不变量 6:会后纪要必须每人不同且真的更短

expect(new Set(notes.map(n => n.text)).size).toBe(3)   // 每人不同
// validateMinutes:纪要必须短于会议记录本身,否则拒绝

validateMinutes 会拒绝"越压越长"的纪要(那说明模型在抄会议而不是在压缩)、空纪要、超长纪要。

不变量 7:停滞由外部计算,不依赖 Agent 自述

detectStall() 四类信号:silent-slot、stale-briefing、repeated-fingerprint(内容指纹连续重复=空转)、 no-progress。

竞品 Caucus 的 ask_operator 完全依赖 Agent 自判"我卡住了"。 但陷入死循环的 Agent 最不可能正确自判——否则它早跳出来了。 判据必须来自外部可观测行为。

而且节流故意留了后门:停滞触发(priority 1)穿透最小会议间隔—— "刚开完会就卡死"恰恰是最需要介入的时刻。


三、安装

从源码构建

git clone https://github.com/liaowenqi123/dsh-meeting-coordinator.git
cd dsh-meeting-coordinator
pnpm install        # 只装依赖。dist/ 已随仓库提交,装完即可被 DSH 加载
pnpm run build      # 只有在改了 src/ 之后才需要

为什么 dist/ 要提交进仓库? 这是实测踩出来的,不是偷懒。

如果靠 prepare 脚本在安装时构建,那么从 GitHub 安装会直接失败:

ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED
The git-hosted package "dsh-meeting-coordinator@0.1.0" needs to execute build
scripts but is not in the "allowBuilds" allowlist.

pnpm ≥10 默认不允许依赖执行构建脚本,而插件市场安装未上 npm 的包走的正是 github spec。把构建产物一并提交,整条安装路径就完全不需要构建脚本了。 代价只有一个:改了 src/ 必须 pnpm run build 并把 dist/ 一起提交(见贡献章节)。

装进 DeepSeek Harness

把本包装进 DSH 的 profile(真实装载契约在 @deepseek-ai/dsh@0.1.6 上核实过):

# 以 web profile 为例
cd ~/.dsh/profiles/web
pnpm add <本包路径>                                     # 本地路径
pnpm add github:liaowenqi123/dsh-meeting-coordinator    # 或直接从 GitHub 装(无需构建)

DSH 不读 dsh-plugin.json(那是标准/市场/准入层的清单,用于「安装前可知兼容性」)。 运行时真正读的是 package.json 的 dsh.bundle.patch → cordis.patch.yml。 两者本包都提供:能被真实装载,也能被静态评估。

重启 DSH(或重载 profile)后,会看到一个会议室面板,可以建会议室、加会话、召集会议。

在插件市场里找它

本仓库带 GitHub topic dsh-plugin,这是社区插件市场 (如 dsh-plugin-marketplace、 DSH-Store)自动同步的索引源—— 装了市场插件就能直接搜到并一键安装。

验证安装

pnpm run verify     # 类型 + 清单静态校验 + 215 测试 + 构建 + 端到端演示

四、快速开始

环境:Node.js ^22.19 || >=24(实测 v24.14.0)+ pnpm。

pnpm install
pnpm run verify     # 一条命令跑完全部验证
步骤命令验证什么
1pnpm run typecheck严格 TS(含 exactOptionalPropertyTypes)
2pnpm run check:manifest不执行任何插件代码,静态判定清单兼容性
3pnpm run test215 个测试:协议 / 会籍 / 状态机 / 会议室 / 主持人 / DSH 模型调用 / 边界监听 / 会话状态驱动 / 停滞升级 / 压缩 / 适配层 / 宿主装配 / 借上下文接线 / 实时会议视图
4pnpm run build产出 dist/(真实装载入口)
5pnpm run probe:composition真实 compose() + LifecycleCoordinator 激活顺序
6pnpm run demo:room会议室机制端到端演示(11 步,全断言)
7pnpm run demo轻量路径演示:简报交换 + 停滞检测 + 唤醒
8pnpm run test:e2e真实 dsh web + Python mock LLM 的端到端(见 5.14;需要本机 Chrome 与 3081 空闲)

demo:room 用 ScriptedMeetingVoice(脚本替身)驱动,因为演示环境里没有活的 DSH 会话; 模型调用通道本身由 tests/dsh-voice.spec.ts 用假上游逐条断言(调用映射、模型透传、 失败处理、资源释放),其中还有一条 apply() 的端到端用例走完整真实代码路径 (会籍 → 激活 → 召集 → 等边界入场 → 每人用自己的模型发言 → 各自纪要 → 散会)。


五、模型调用与等待入场:怎么接到上游

4.1 让会话说一句话

上游 DSH 有两条多 Agent 通道,用途不同,本项目两条都用、各司其职:

通道能力本项目用途
ctx.agentTeams(TeamService)持久 peer 信箱;sendMessage 只回 {messageId, status},不回传发言内容唤起 / 通知成员(dsh-team-runtime.ts)
ctx.subagents(SubagentRuntime)start() 返回 SubagentRun,run.result 能读回最终 assistant 输出让会话说一句话(dsh-meeting-voice.ts)

所以"发言"走 subagents:

const run = await ctx.subagents.start('spawn', {
  label, prompt: [{ type: 'text', text }], parent, signal,
  agentOptions: { model },        // ← 按会话指定模型,兑现"每个参会用自己的模型"
})
const result = await run.result   // SubagentResult
result.output                     // ContentBlock[],最后一个非空 assistant 消息
await run.dispose()

优先 fork 那个人过来:

const run = await ctx.subagents.start('fork', { parent: 那个人, prompt, persona, agentOptions: { model } })

fork 的原生语义正是这件事(@deepseek-ai/dsh-subagent-fork-in-process):

seeds each child with the parent’s completed conversation turns: the child sees every finished turn The seed is a one-time snapshot taken at fork time

也就是:把那个人已完成的上下文一次性 seed 进这个子会话,于是进会场的那个人带着自己的完整上下文——它就是它本人,不是「读过它资料的陌生人」。这就是「掏过来,当作我的上下文注入」。

拿不到 fork(或该会话不是活的)时退回 spawn,由 prompt 里的完整上下文注入兜底。 理由有三:只有这条路能读回输出;单轮上下文恒有界(避免 N 人 × R 轮把上下文撑爆); 会话的身份与记忆由 AgentParticipant 持有,不会因上游会话生命周期漂移。

4.2 三个必须处理的失败模式

// ① result 在子级失败时不 reject,而是带 stopReason 解析 —— 忽略它最危险
if (stopReason !== 'completed') throw new VoiceUnavailable(...)
// ② 空输出 ≠ 成员没意见
if (text.length === 0) throw new VoiceUnavailable(...)
// ③ 超时只发 abort 信号不够:上游若忽略信号,await 会永远挂住 → 必须显式竞速
const result = await raceWithAbort(run.result, signal, label)

第 ① 条尤其关键:一场因为模型全挂而沉默的会议,看起来会像"大家都没意见"。 把失败当成沉默,比报错危险得多。这一条是被测试逼出来的真实 bug 修复 (超时测试最初直接超时挂住,暴露了只发信号不竞速的问题)。

4.3 等工作单元结束再入场

需求:"一个 agent 正在 working(不论是正在输出还是在调用工具 ing), 都将在调用结束后进入会议室(有一个等待的过程)。"

上游会话事件词汇表里正好有两个可用的边界:

事件含义何时用
step/end一个步骤结束(含该步里的工具调用)默认,最贴近"调用结束后"
turn/end整个回合结束希望成员把手头这轮彻底做完再进场

接线(dsh-boundary-watcher.ts):

ctx.on('session/event', (session, event) => {
  if (event.type !== 'step/end') return          // 只看配置的边界
  const memberId = resolveMemberId(session.id)   // 可配的会话→成员映射
  if (memberId) orchestrator.onWorkUnitComplete(memberId)  // 到边界才真正入场
})

两个刻意的设计:边界类型与身份映射都可配(上游事件名是内部词汇表的一部分, 未来可能变);监听器永不抛错(事件回调抛异常会污染宿主的事件分发,这里捕掉并记进诊断)。


六、两条路径:什么时候开会,什么时候只同步

        ┌─────────────────────────────────────────────────────────┐
        │  轻量路径:简报交换(MeetingCoordinator + BriefingBoard) │
        │  · 每 N 轮 / 4 小时:限长摘要落板 + 汇总广播(无模型调用)│
        │  · 持续计算停滞信号(指纹重复 / 轮次落后 / 无进展)      │
        └───────────────────────────┬─────────────────────────────┘
                                    │ 检测到停滞 / 按需召集
                                    ▼
        ┌─────────────────────────────────────────────────────────┐
        │  正式路径:会议室(MeetingOrchestrator + MeetingRoom)   │
        │  · 召集全体成员(含 idle / done)                        │
        │  · 入场 prompt → 主持人控场多轮讨论 → 每人个性化压缩     │
        │  · 散会后全部回到 working                                │
        └─────────────────────────────────────────────────────────┘

为什么两条都要:

  • 高频低成本。每次开会都要 N×R 次模型调用。日常节奏对齐用简报板就够, 它只是追加写 JSONL + 一次汇总,零模型调用;
  • 贵的事情只在需要时做。停滞信号是触发会议室的判据,而不是让 Agent 自己喊"我要开会";
  • 这也符合调研结论:便宜轮询 + 只在必要时开昂贵会议。 Anthropic 实测多 Agent 系统约 15× chat token,这个成本必须被理由支撑。

5.1 那条箭头是怎么落的:pulse()

上面那张图里 轻量路径 ──检测到停滞/按需召集──▶ 正式路径 这条箭头, 落在 host.ts 的 pulse() 上(不是 tick()):

pulse(): Promise<MeetingPulseResult> {
  const light = await local.tick()                    // 轻量路径:简报落板 + 汇总广播
  return { light, escalation: await escalate(light.evaluation) }
}

三个刻意的决定:

  1. 触发评估只跑一次,升级复用它。evaluateTriggers 依赖协调器的 lastMeetingAt / lastMeetingRound,跑两遍会把节流状态推乱—— 于是"该不该开会"和"该不该升级"必须看同一份评估。
  2. 升级失败不抛错,只带回理由。全员在工作中、已有一场会在进行, 都是运行期的正常分支。把它当异常,定时器会把整棵插件树炸掉。
  3. 召集者用人类席位(human),不是某个成员。这是外部干预: 停滞由外部计算,不依赖 Agent 自述。借用某个成员的身份会同时污染两件事—— 主持人会拿到那个成员的模型,审计里也会记成"是它召集的"。

5.2 成员状态必须被喂,否则会议室永远开不起来

AgentParticipant 的初始状态是 working,而 convene() 要求至少一位 被召集成员能立刻入场(idle-waiting / done 直接入会;working 只能进 awaiting-entry)。

所以"接上 convene()"还不够——状态机不喂,整条正式路径就是死代码: 全场永远 working,convene() 永远抛「全部成员都在工作中,会议无法开始」。

两个来源共同驱动状态,刻意分成两条独立订阅:

关注点谁负责依据
"这个会话忙起来了 / 空闲了"dsh-session-state.ts会话事件 turn/start / turn/end
"这个会话的工作单元走到边界了"dsh-boundary-watcher.ts可配的 step/end / turn/end

不合并的理由:入场时机是可配置的策略,而活动状态是客观事实。 混在一个订阅里,改入场策略就会连带改状态语义。

5.3 会话 id 不是成员 id(最容易误判成"插件没生效"的坑)

上游 session/event 带的是 DSH 自己的会话 id(UUID),跟我们配置里的 slot id 毫无关系。映射不上时:日志不报错、插件也确实装好了, 但没人被登记为空闲、没人入场、会议室永远开不起来。

三级来源,优先级从高到低:

  1. config.memberSessions 显式映射(用户说了算);
  2. 自动探测 agentTeams.listMembers():上游的 TeamMemberView 同时带 id: SessionId 与 name,而成员是我们用 teammateName(slot) 起的名字, 按名字对齐就拿到权威会话 id;
  3. 恒等映射(会话 id 恰好就是 slot id 的场景,主要是测试)。

升级失败时,理由里会直接点名未映射的会话 id 并告诉你去配什么—— 不让这类配置错误表现成"插件没生效"。

5.4 上游要的是"活的 Agent",不是 Agent 服务(真机踩过的坑)

agentTeams / subagents 的第一个参数是授权凭据,上游的判据非常具体:

tryMembership(agent) {
  if (this.ctx.agents.get(agent.id) !== agent) return undefined   // ← 必须同一个对象引用
  …
  throw new TeamError(`agent "${agent.id}" is not a member of an active Agent Team`)
}

所以绝不能把 ctx.get('agents') 这个服务对象直接传下去。 实测的错误信息是 agent "undefined" is not a member of an active Agent Team—— 上游读 agent.id,而服务对象没有 id。而且那个错会以 plugin tree failed to load 的形式把整个 dsh 带下线。

resolveLiveAgent() 按三级取值,顺序有理由:

顺序来源何时命中
1agents.currentInitiator()Agent 驱动的调用链(最准:就是"谁发起的")
2agents.roots()[0]插件加载与定时器路径——currentInitiator() 是 AsyncLocalStorage 语义,这些路径下必然 undefined
3agents.list()[0]兜底:任何活着的 Agent 都能当凭据

5.5 启动成员需要活的 Agent,而插件是在启动时加载的

这是上面那条的必然推论,也是必须先解决才能装上的问题:

dsh web 刚启动时,用户可能还没打开任何会话——一个 Agent 都没有。

此时如果成员 facet 在激活期抛错,后果不是"这个成员没起来", 而是 plugin tree failed to load → 整个 dsh 起不来。

所以成员启动失败即降级、不抛错,并分成两类:

失败类型处理理由
协商类(拿不到 agreement、boardDomain 不一致)抛契约错误,不是时机问题
启动类(没有活 Agent、上游拒绝)降级 + 重试只是时机未到,pulse() 每轮重试(用户开会话后就该成功)

降级的影响面是有限的:成员句柄只服务于轻量路径的消息投递; host.pendingMembers 会如实列出没起来的成员,升级失败的理由里也会点名它们。

⚠️ 但发言仍然需要活的 Agent(subagents.start 的 parent 走同一个 resolveCaller)。 所以没有活 Agent 时会议是真的开不起来——插件不会假装"大家都沉默",而是明确报错。

5.6 Cordis 只接受"函数或空"作为 apply 的返回值

这一条是真机 boot 第二次崩出来的:TypeError: Invalid effect。

Cordis 判定插件 effect 的代码是(Fiber._execute → safeCollect):

const effect = runner.execute.call(this)
if (typeof effect === 'function') return runner.collect(effect)   // 函数 = disposer,合法
else if (isNullable(effect)) {}                                   // 什么都不返回,合法
else if (!isObject(effect)) throw new TypeError('Invalid effect')
else if ('then' in effect) return effect.then(safeCollect)        // ← async apply 的 resolve 值走这里

safeCollect 只接受函数或 null/undefined。所以 async apply 的 resolve 值只能是 undefined 或一个 disposer 函数——直接 resolve 一个句柄对象就会让整个 dsh 起不来。

真实上游插件(dsh-session-header 等)都是同步 apply、返回 void,清理走 ctx.effect(() => { …; return () => cleanup })(callback 立即执行,它的返回值才是 disposer)。

本插件的 apply 因此返回一个 disposer 函数,句柄挂在它的 .host 属性上:

const { host } = await apply(ctx, { slots, rootDir, boardDomain })   // 程序化 / 测试
// 真机装载时 Cordis 把这个函数当 disposer 收集,卸载时调用它

拆卸过程是幂等的,因为它会被三个入口触发:返回的 disposer、ctx.effect 的清理、 ctx.on('dispose')——Cordis 卸载时可能三路都到。

5.7 默认什么都不做,以及 Cordis 服务访问的两个陷阱

默认空会籍:apply() 不建房间、不拉人入会、不启定时器。房间由人在面板上建, 成员由人把非活动会话加进去。上一版不是这样——它会自动建房间、把配置里的 slots 全拉进会、再起一个 60 秒定时器(onStall: true 让第一轮就判停滞),结果是 "插件一装上就自己开始每分钟开会",而用户既看不到也停不掉。

约束理由
默认无房间、无会籍会籍是权限边界,默认满员等于边界从第一天就失效
入会那一刻必须非活动入会要往该会话装工具;往正在跑的 Agent 上做会扰动它当前的任务。口径是时点:加入之后它可以随便忙
退出同样要求非活动退出是卸工具,与入会同属"改该会话工具集"。于是只有这两个时点会碰它——会议本身不碰它
子 Agent 不许入会否则一个会话 spawn N 个子 Agent 就能自我增票。判据 header.parentSession / origin === 'subagent'
自动节律默认关要无人值守才 autoPulse: true

⚠️ 两个陷阱(连崩三次才搞清,必须记住):

  1. 未在 inject 里声明的服务,属性访问会直接抛: cannot get property "tools" without inject——不是返回 undefined。
  2. 把它加进 inject 又会让整个 boot 挂死:inject 会等该服务出现, 而这个 profile 里它不在本入口之前就绪 → 入口一直等,boot() 不返回。 症状是进程活着、零输出、端口不监听——最难查的一种。
  3. 唯一安全的读法是 ctx.get(name)(readService 内部还有 try/catch)。 所以 inject 只列 apply() 期间真正必需 的服务,其余一律惰性 get。 已验证可用的最小集:inject: [agents, subagents, agentTeams]。

全局工具就是那个"按钮":ctx.tools.register() 在 apply() 里调用即为全局注册; 而 ToolSchema.description 本来就在该会话的模型上下文里—— 所以需求里"像系统提示词一样注入一大坨(新 toolcall + 权限 + 义务)"落地下来就是 写 description。按钮是它,说明书也是它。

5.8 轮次结论必须有去处

pulse() 跑在定时器里,而本插件没有 UI、也(暂时)没有命令。所以"升级失败的原因" 如果丢掉,用户看到的就只是什么都没发生——这是最难排查的一种表现。

两个通道,只在结论变化时报告(轮次本身是每分钟一次的心跳,原样刷日志等于没日志):

通道内容可靠性
ctx.logger.info/warn[dsh-meeting] 已升级/未升级到会议室:…尽力而为。Cordis 内建 logger 的默认导出器只进内存缓冲(上限 1000 条),实测没出现在 dsh web 的 stdout
<rootDir>/pulse.jsonl结构化记录(escalated / reason / signals / trigger / meeting id)实测可用,直接 cat 就能看

一个自己踩的坑:诊断文本里如果有累加的数组(比如每轮都 push 一条相同错误), 文本每次都会变,"结论变化才报告"的去重就完全失效——等于每轮刷一遍。 所以诊断里的错误集合用 Set。

5.9 会议室面板(浏览器半边)与四个把界面吃掉的真机坑

面板就是需求里那个"独立面板":侧栏底部的 🗂 会议室 按钮打开一个全屏覆盖层, 左边是所有会议室,右边是选中房间的成员 / 候选会话(能加的与加不了的,都带理由)/ 历次会议正文, 并可直接建房间、加会话、移出、召集会议。

实时会议视图("看得见会议过程")。 会议是几十秒的多轮过程,只有散会后的记录的话, 用户全程只能对着一块静止的界面猜。面板打开期间每 2 秒轮询一次 meeting/liveMeeting (第 8 个端点):有会正在开时,房间详情顶部出现"● 会议进行中"区块—— 议题、第 X/Y 轮、召集者、在场/未到场名单,以及持续增长的实时记录 (入场引导 / 发言 / 主持人控场,自动滚动到最新一条);散会后区块消失, 历次会议里随即能看到这场会的完整记录。为什么必须轮询而不是"点召集后刷新": 会议可能由任何人发起——面板按钮、Agent 调全局工具、停滞自动升级, 后两种压根不经面板的手。数据面是 MeetingConsole.liveMeeting(), 每次调用向编排器重算一份快照(activeMeeting()),不缓存—— 缓存会让面板看到过期的轮次,那比没有这个视图更糟。

实现要点:客户端半边不需要打包器。 契约只有一条——文件自己调 window.__ModuleLoader__.load({ id, factory }), 工厂里 require('react') 由宿主注入,返回一个 Cordis 插件 { name, inject, apply }。 上游那些几百 KB 的 client.js 是 TSX 多文件产物;我们只有 client.js 一个文件、 只用 React.createElement,所以手写比接一套构建更简单。

数据怎么来。 浏览器读不到文件。数据经 Typert Gateway 的通用 RPC 通道取:

ctx.get('connection').rpc.call('/api', 'meeting/overview', { args: { request: { viewerId: 'human' } } })

宿主侧由 src/adapters/dsh-meeting-remote.ts 提供那 8 个端点。

唯一一次"按契约复刻上游"的地方。 上游的写法是 class X extends TypertRemoteService + @Remote,但那要 import @deepseek-ai/dsh-typert-protocol——本包是 link: 安装的,会引入第二份 cordis。 我们从上游源码读出等价契约,不 import:

  1. ctx.provide(key, value) 会让 ctx.reflect.props[key] 变成 { type: 'service' }(gateway 按此发现);
  2. 服务实例上要有 typertRemote,且 binding.service === 实例自身(上游是 !== 就抛);
  3. 原型上要有一个普通字符串键的描述符 '@deepseek-ai/dsh-typert-protocol/remote-methods' → { version: 1, methods: [{ method, invocation: { kind: 'direct' } }] }。

第 3 条是关键:它不是 Symbol,所以不 import 也能写出等价描述符。 代价是上游改了这个键我们会静默失效(端点 404,不报错), 因此 tests/meeting-remote.spec.ts 把这 3 条连同"每个方法恰好一个名为 request 的形参" 一起钉成断言——形参名即 wire 字段名,上游 assertExactArguments 要求实参键与形参名完全一致。

真机上撞到的三个坑,代价都是"界面完全没有":

#现象根因
1插件装了,UI 上什么都没有inject 里写了 agentTeams,而 agent-team 那两层已从 profile 删除 → Cordis 的 inject 一直等这个服务 → 入口永远 pending,apply() 根本没跑。inject 写错名字的代价是静默不激活,不是报错。
2按钮在,点开没反应makeClient(ctx) 建在组件函数体里 → useCallback/useEffect 的依赖每次渲染都是新引用 → 效果反复触发 → 无限渲染循环。改为在 apply() 期构造一次。
3整个 dsh 起不来加载期探测 ctx.subagents 的 provider 就 throw。那是拿加载期的探测去判定调用期的事实(加载那一刻上游 provider 还没注册完)。改为降级 + 记诊断,判据落到真正开会的时刻。
4列表里全是 session-750e30ff-…,认不出是哪个会话宿主里写着 titleOf: (sessionId) => sessionId —— 把 id 当成了标题。它还是个"看起来无害的兜底",实际把后面所有标题解析全短路了。已删;标题改由内置解析读 sessionTitle 服务(session/title 事件的 latest-wins 折叠),没有标题事件时退回按首条人类消息派生。

5.10 会话标题:面板里必须显示名字而不是 id

标题的真来源是 Cordis 服务 sessionTitle(@deepseek-ai/dsh-session-title,由 dsh-base 装载): ctx.get('sessionTitle').get(session) → { title, source, eventSeq }。

两级读法(都在 dsh-session-catalog.ts 的 readSessionTitle() 里,不 import 上游包):

级条件结果
1有 session/title 事件(用户改名 / 模型生成 / 内置回退)用它的 title
2没有(新建会话还没起过名)用第一条人类消息派生单行短标题