dsh-loopx-plugin
loopx-project/loopxLoopX——面向长周期 Agent 的提供商中立、本地优先状态内核与控制平面:在 DeepSeek Harness 执行层之上持久化 Goal、Todo、门禁、证据、配额、恢复与交接状态;插件负责引导安装 CLI 与技能、准入有界的同会话续跑,并为精确绑定的工作循环提供本地 GoalBar。
给 DSH 会话装上会议室:加入会议室即同时获得召集权与参会义务,且一个会话只能属于一个会议室。召集时把每位参会者 fork 进场——带着它自己已完成的轮上下文,同时剥掉全部工具;主持人自身不带任何上下文,只负责挑下一位发言者;每条发言在说出的当下就落盘。散会后每人拿到一份只属于自己的纪要,作为一条用户输入送回工作区继续干活。
给一群长跑的 Agent 装上「会议室」与「互相唤起」。 基于 dsh-std 元协议开发,为 DeepSeek Harness 提供多 Agent 协作。
⚠️ 早期阶段 / Early stage. 核心机制已经跑通并有 215 个测试覆盖,但还没在真实生产场景里长期跑过。 遇到问题请开 Issue —— 非常欢迎反馈、提 bug、发 PR,见欢迎贡献。
单个 Agent 长时间工作时会得三种病:上下文污染、死循环(在自己的上下文里反复确认同一个错误结论)、 遗忘全局目标。多 Agent 并行的方案很多,但真正难的是"持续循环跑下去"。
本插件的答案不是更好的提示词,而是给 Agent 们一个组织。
关键区分——不是会话属于会议,而是会话加入会议室:
会话 A ──┐ ┌── 会话 B
│ 加入(主动) │
├──▶ ┌──────────┐ ◀──┤
│ │ 会议室 │ │
会话 C ──┘ └──────────┘ └── 会话 D(没加入)
│
叫不动,也不会被误叫
这一条解决的是"不是每一个会话都是可被召集或唤起的": 会籍就是权限边界。你的私人助手会话不会因为某个 Agent 想开会就被拉进群聊。
不是"协调器把摘要广播出去"。是:
每个会话带着自己的记忆,临时进入会议室; 入场时预注入一段 prompt(说明议题、在场有谁、你可以说什么); 会议室是一个多轮群聊,由主持人控场,像真的开会一样互相回应; 会后每个会话各自对会议做压缩,只留下与自己相关的内容; 回去之后,记忆变成「原私有上下文 + 自己相关的会议纪要」。
关键在于每人拿到的纪要是不同的。如果是"一份全局会议纪要发给所有人", 那只是把广播换了个说法,别人的全部发言会原封不动挤进我的上下文——那正是污染。
会话 A(记忆A) 会话 B(记忆B) 会话 C(记忆C)
│ │ │
└──────────┬───────┴───────┬──────────┘
▼ ▼
┌────────────────────────────────────────────┐
│ 会议室(多轮群聊,共享 transcript) │
│ │
│ 入场:定位 prompt + 各自记忆的限长投影 │
│ 发言:主持人控场(主持人没有上下文) │
│ 人类可随时插话(不占轮次) │
└────────────────────────────────────────────┘
│ │
▼ ▼
A 的个性化纪要 B 的个性化纪要 C 的个性化纪要
(内容各不相同)
│ │
▼ ▼
记忆A + 纪要A 记忆B + 纪要B 记忆C + 纪要C
一个 agent 选择开会后,可以召集所有在工作或者不在工作的 agent (不在工作状态指的是输出完毕等待用户回复,或已完成任务), 开完会后,所有 agent 都会回到工作状态。
所以"唤起"是状态层面的动作,而且正在工作的会话不会被会议打断:
summon()
working ─────▶ awaiting-entry ──(当前工作单元结束)──▶ in-meeting
idle-waiting ────────────────────────────────────▶ in-meeting
done ───────────────────────────────────────────▶ in-meeting
dismiss() ──▶ 召集前的状态(还原,不是一刀切)
working(正在输出 / 正在调用工具)→ 先进 awaiting-entry。
不打断,等宿主在"本次输出结束 / 本次工具调用返回"的边界调 onWorkUnitComplete() 才入场。
这就是需求里说的"有一个等待的过程";idle-waiting(等用户回复)和 done(任务已完成)没有工作单元要等,直接入场;working",是一个会让会议室一次性报废的 bug:
从 idle-waiting 被叫来的成员散会后被谎报成"正在干活",下一次召集就永远等一个
不会到来的工作单元边界——"再点一次召集,就告诉我全员在工作中"。
详见 5.12。设置一个主持人吧,模型使用唤起会话的模型,但是这个主持人直接没有上下文, 不会被任何人的上下文带偏。
这条把两件事彻底解耦了:
主持人只拿到群聊记录 + 成员名单 + 谁还没说话,拿不到任何人的私有记忆投影。 因此它不可能因为"某人的上下文更长/更详细"而偏好某人—— 这就是"不会被带偏"的机制保证,而不是一句提示词约定。
主持人看到的: 议题 / 群聊记录 / 名单 / 谁没说话 / 累计发言次数
主持人看不到: 任何人的工作记忆、代码、日志、历史会话
它用的是召集者会话的模型:谁开的会,就像谁在主持,风格一致, 且不需要为协调器单独引入一个模型依赖。
我觉得没必要做结构化的东西,AI 会自动讨论出合理结果的(这个主要还是针对 AI agent 的集会)
所以:
主持人输出的控制决定是 JSON({"action":"invite","next":"..."} 或 {"action":"adjourn"})。
那不是给 AI 的模板,而是协调器与主持人之间的控制通道;
而且解析失败时绝不会卡住会议——会回退到安全轮转。
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
每一条都有对应的失败断言在测试里,不是口号。
只有加入了会议室的会话才能召集、才会被召集。测试断言:
// 没加入的会话越权召集 → 被拒
await expect(orchestrator.convene({ calledBy: 'data-infra', ... })).rejects.toThrow(/没有召集权/)
// 排他会籍
expect(() => orchestrator.joinRoom('neural-net', 'other-room')).toThrow(RoomRegistryError)
会籍落盘(追加式 JSONL),进程重启后可恢复——内存态的群成员关系会在重启后静默消失, 让"唤起"变成无声失败。
进会议室时注入的是记忆的限长投影(memoryProjection(maxChars)),不是完整上下文:
expect(liveEntry.text).toContain('逐笔滑点重算完成') // 自己的记忆
expect(liveEntry.text).not.toContain('残差宽度消融') // 别人的私有记忆
为什么不共享上下文?因为共享上下文就是"上下文污染"本身。 Cognition 主张"别做多 Agent、上下文必须共享"——那是针对短周期、强耦合任务。 本项目面向长周期、弱耦合、方向可切分的场景,隔离才是解药。
expect(() => room.appendSpeech('a', '一'.repeat(801))).toThrow(/超长/)
静默截断比超长更糟:Agent 会以为自己的诉求已经传达了。
依据:调研发现"200 字"这个具体数字没有研究支持,是工程取舍。 但"硬上限 + 分字段预算"有先例(LangChain 的
max_token_limit滚动摘要、 Caucus 把工具描述硬裁到 ≤260 字符)。所以本实现的是机制,上限是可配默认值。
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 的失败模式是"静默且看似成功",所以坐标必须选一个不依赖启动目录的地方。
// 工具写出的路径 === 宿主 stopRequested 读的路径
expect(existsSync(join(configRoot, 'stop.flag'))).toBe(true)
// 且不需要 roomId
expect((await runMeetingTool({ args: { action: 'stop' }, ... })).ok).toBe(true)
这里曾经同时坏了两处,而且是互相掩护的:
registerMeetingTool 根本没接 rootDir,自己又推算了一遍 —— 于是只要 config 里
给了 rootDir,工具写的标志文件和宿主读的不是同一个文件;roomId 校验排在 stop 分支之前 —— 于是 action=stop 永远走不到自己的分支,
一律返回"action=room / convene 需要 roomId"。两者叠起来的表现是:调用 stop 得到 ok: true 和一条看似正确的路径,
而会议毫无反应。静默失效的急停比没有急停更糟——人会以为自己已经叫停了。
现在 rootDir 由宿主传入(同源),且 stop 不受任何房间前置条件约束。
expect(new Set(notes.map(n => n.text)).size).toBe(3) // 每人不同
// validateMinutes:纪要必须短于会议记录本身,否则拒绝
validateMinutes 会拒绝"越压越长"的纪要(那说明模型在抄会议而不是在压缩)、空纪要、超长纪要。
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/一起提交(见贡献章节)。
把本包装进 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 # 一条命令跑完全部验证
| 步骤 | 命令 | 验证什么 |
|---|---|---|
| 1 | pnpm run typecheck | 严格 TS(含 exactOptionalPropertyTypes) |
| 2 | pnpm run check:manifest | 不执行任何插件代码,静态判定清单兼容性 |
| 3 | pnpm run test | 215 个测试:协议 / 会籍 / 状态机 / 会议室 / 主持人 / DSH 模型调用 / 边界监听 / 会话状态驱动 / 停滞升级 / 压缩 / 适配层 / 宿主装配 / 借上下文接线 / 实时会议视图 |
| 4 | pnpm run build | 产出 dist/(真实装载入口) |
| 5 | pnpm run probe:composition | 真实 compose() + LifecycleCoordinator 激活顺序 |
| 6 | pnpm run demo:room | 会议室机制端到端演示(11 步,全断言) |
| 7 | pnpm run demo | 轻量路径演示:简报交换 + 停滞检测 + 唤醒 |
| 8 | pnpm run test:e2e | 真实 dsh web + Python mock LLM 的端到端(见 5.14;需要本机 Chrome 与 3081 空闲) |
demo:room用ScriptedMeetingVoice(脚本替身)驱动,因为演示环境里没有活的 DSH 会话; 模型调用通道本身由tests/dsh-voice.spec.ts用假上游逐条断言(调用映射、模型透传、 失败处理、资源释放),其中还有一条apply()的端到端用例走完整真实代码路径 (会籍 → 激活 → 召集 → 等边界入场 → 每人用自己的模型发言 → 各自纪要 → 散会)。
上游 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 持有,不会因上游会话生命周期漂移。
// ① 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 修复 (超时测试最初直接超时挂住,暴露了只发信号不竞速的问题)。
需求:"一个 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 │
└─────────────────────────────────────────────────────────┘
为什么两条都要:
pulse()上面那张图里 轻量路径 ──检测到停滞/按需召集──▶ 正式路径 这条箭头,
落在 host.ts 的 pulse() 上(不是 tick()):
pulse(): Promise<MeetingPulseResult> {
const light = await local.tick() // 轻量路径:简报落板 + 汇总广播
return { light, escalation: await escalate(light.evaluation) }
}
三个刻意的决定:
evaluateTriggers 依赖协调器的
lastMeetingAt / lastMeetingRound,跑两遍会把节流状态推乱——
于是"该不该开会"和"该不该升级"必须看同一份评估。human),不是某个成员。这是外部干预:
停滞由外部计算,不依赖 Agent 自述。借用某个成员的身份会同时污染两件事——
主持人会拿到那个成员的模型,审计里也会记成"是它召集的"。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 |
不合并的理由:入场时机是可配置的策略,而活动状态是客观事实。 混在一个订阅里,改入场策略就会连带改状态语义。
上游 session/event 带的是 DSH 自己的会话 id(UUID),跟我们配置里的
slot id 毫无关系。映射不上时:日志不报错、插件也确实装好了,
但没人被登记为空闲、没人入场、会议室永远开不起来。
三级来源,优先级从高到低:
config.memberSessions 显式映射(用户说了算);agentTeams.listMembers():上游的 TeamMemberView 同时带
id: SessionId 与 name,而成员是我们用 teammateName(slot) 起的名字,
按名字对齐就拿到权威会话 id;升级失败时,理由里会直接点名未映射的会话 id 并告诉你去配什么—— 不让这类配置错误表现成"插件没生效"。
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() 按三级取值,顺序有理由:
| 顺序 | 来源 | 何时命中 |
|---|---|---|
| 1 | agents.currentInitiator() | Agent 驱动的调用链(最准:就是"谁发起的") |
| 2 | agents.roots()[0] | 插件加载与定时器路径——currentInitiator() 是 AsyncLocalStorage 语义,这些路径下必然 undefined |
| 3 | agents.list()[0] | 兜底:任何活着的 Agent 都能当凭据 |
这是上面那条的必然推论,也是必须先解决才能装上的问题:
dsh web刚启动时,用户可能还没打开任何会话——一个 Agent 都没有。
此时如果成员 facet 在激活期抛错,后果不是"这个成员没起来",
而是 plugin tree failed to load → 整个 dsh 起不来。
所以成员启动失败即降级、不抛错,并分成两类:
| 失败类型 | 处理 | 理由 |
|---|---|---|
| 协商类(拿不到 agreement、boardDomain 不一致) | 抛 | 契约错误,不是时机问题 |
| 启动类(没有活 Agent、上游拒绝) | 降级 + 重试 | 只是时机未到,pulse() 每轮重试(用户开会话后就该成功) |
降级的影响面是有限的:成员句柄只服务于轻量路径的消息投递;
host.pendingMembers 会如实列出没起来的成员,升级失败的理由里也会点名它们。
⚠️ 但发言仍然需要活的 Agent(
subagents.start的parent走同一个resolveCaller)。 所以没有活 Agent 时会议是真的开不起来——插件不会假装"大家都沉默",而是明确报错。
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 卸载时可能三路都到。
默认空会籍:apply() 不建房间、不拉人入会、不启定时器。房间由人在面板上建,
成员由人把非活动会话加进去。上一版不是这样——它会自动建房间、把配置里的 slots
全拉进会、再起一个 60 秒定时器(onStall: true 让第一轮就判停滞),结果是
"插件一装上就自己开始每分钟开会",而用户既看不到也停不掉。
| 约束 | 理由 |
|---|---|
| 默认无房间、无会籍 | 会籍是权限边界,默认满员等于边界从第一天就失效 |
| 入会那一刻必须非活动 | 入会要往该会话装工具;往正在跑的 Agent 上做会扰动它当前的任务。口径是时点:加入之后它可以随便忙 |
| 退出同样要求非活动 | 退出是卸工具,与入会同属"改该会话工具集"。于是只有这两个时点会碰它——会议本身不碰它 |
| 子 Agent 不许入会 | 否则一个会话 spawn N 个子 Agent 就能自我增票。判据 header.parentSession / origin === 'subagent' |
| 自动节律默认关 | 要无人值守才 autoPulse: true |
⚠️ 两个陷阱(连崩三次才搞清,必须记住):
inject 里声明的服务,属性访问会直接抛:
cannot get property "tools" without inject——不是返回 undefined。inject 又会让整个 boot 挂死:inject 会等该服务出现,
而这个 profile 里它不在本入口之前就绪 → 入口一直等,boot() 不返回。
症状是进程活着、零输出、端口不监听——最难查的一种。ctx.get(name)(readService 内部还有 try/catch)。
所以 inject 只列 apply() 期间真正必需 的服务,其余一律惰性 get。
已验证可用的最小集:inject: [agents, subagents, agentTeams]。全局工具就是那个"按钮":ctx.tools.register() 在 apply() 里调用即为全局注册;
而 ToolSchema.description 本来就在该会话的模型上下文里——
所以需求里"像系统提示词一样注入一大坨(新 toolcall + 权限 + 义务)"落地下来就是
写 description。按钮是它,说明书也是它。
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。
面板就是需求里那个"独立面板":侧栏底部的 🗂 会议室 按钮打开一个全屏覆盖层, 左边是所有会议室,右边是选中房间的成员 / 候选会话(能加的与加不了的,都带理由)/ 历次会议正文, 并可直接建房间、加会话、移出、召集会议。
实时会议视图("看得见会议过程")。 会议是几十秒的多轮过程,只有散会后的记录的话,
用户全程只能对着一块静止的界面猜。面板打开期间每 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:
ctx.provide(key, value) 会让 ctx.reflect.props[key] 变成 { type: 'service' }(gateway 按此发现);typertRemote,且 binding.service === 实例自身(上游是 !== 就抛);'@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 折叠),没有标题事件时退回按首条人类消息派生。 |
标题的真来源是 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 | 没有(新建会话还没起过名) | 用第一条人类消息派生单行短标题 |