dsh-domain-bridge
Verifieddsh-domain-bridge · v0.6.4 · MIT
域可达层:声明优先的域寻址、会话工厂、四级「已暂停」、唤醒接线与模型分派。工具清单与口径见 README.md,变更史见 CHANGELOG.md。
Install
dsh plugin add dsh-domain-bridge Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
dsh-domain-bridge
域可达层 v0.6.4(安全默认:domainSessionPreset 缺省值由 danger-full-access 收窄为 workspace-write,要全盘写权限必须自己显式配置;无其它行为变化),模型分派见 list_models / set_model / set_default_model——给"顶层会话之间互相看得见、发得到"补一段基础能力,让域会话能自己认领/释放在任声明,并让任一宿主侧会话能直接开一个真顶层域会话(create_domain_session)把开工任务排进去。v0.4 起还能把目标按成「已暂停」:插件介导的投递不再硬投,而是落盘入队(可撤回),恢复时按原样重投;v0.4.1 修掉了「插件把离线会话拉活之后,它一跑回合就崩」那个 bug({{model}} 唤醒 bug);v0.4.2 把「暂停」从域级推广到四级(域 / 主=单会话 / 项目 / 全局),判定=OR 叠加、两端都查、global 要 confirm、恢复时重评 OR。
v0.4.2 一句话:任一级被按下就拦,拦的时候把「是哪几级按的」全部说出来;解一级不够——得把压着目标的那几级都解开,才会重投。
v0.5.0 一句话:插件包里不写任何机器路径、也不存运行期数据——
<home>/config.json管本机路径,<home>/state/管暂停记录与落盘队列,首次加载把包内历史state/复制过去(源文件不动)。
v0.6.0 一句话:
list_models只读目录,set_model只改一个会话的模型;因为宿主改会话会顺手把选择写成全局默认,本插件一律「先记 → 改 → 恢复」,并把globalDefault:{before, after, restored}如实写进回执。
v0.6.1 一句话:首次真机验证暴露两个缺陷——
modelCatalog()是 async(v0.6.0 少了await,目录恒空)与会话投影把"会话 id 字符串"喂给了只要 Session 对象的stateOf(必抛)——本版都修了,并补上"统一改模型入口"的全局那一半set_default_model(显式改全局默认、永不恢复)。
⚠️ 安全前提(用之前先读):本插件把「会话的管理权」交给模型——
force:true(越过暂停/强制交接)、set_default_model(改全局默认模型)、permissionPreset(改新会话权限档)只靠模型遵守工具描述,没有独立授权校验。会话被内容注入诱导时,可能做出你没授权的跨会话操作。另外:项目级暂停不中断已开跑的回合;未读的inject队列在 dsh 重启时可能被整体取消(要保证送达用followup);stateDir/domainsFile按可信输入处理,未做符号链接与穿越校验。完整威胁模型见仓库根的SECURITY.md。
它解决什么
现状(dsh 0.2.1-rc.2 实测):模型侧 send_message / list_agents 只认自己的子树(直系父子)⇒ 用户手开的顶层会话(我们的"域会话")之间互相看不见、也发不到,只能靠人当信使搬运。
本插件补上这一段:
| 工具 | 作用 |
|---|---|
list_domain_sessions |
列出当前进程里在线的顶层会话,每个都带:索引域标注 + 是否持有在任声明(onDuty / dutyDomain / dutyDomainKey)+ 派生状态(state / paused / pausedDomains / pausedScopes / pausedByScopes——四级 OR;在线·已暂停 不等于被禁言,读法见「四级」一节);并返回本会话自己的身份(id/域/onDuty)。另有 declarations 段=域名册全量在任声明(每条标出会话 online: true/false 与派生状态)与 domainState 段(五态字典 + 四级作用域 + 被按下记录 + 「已终结」墓碑 + 判不成的级别)。只读,不唤醒任何会话。 |
send_to_domain |
把一条消息投递给某个在线会话:target 可填域(asr / misas:asr)或会话 id。默认 followup(=对方窗口里来了一条普通消息,唤醒它开一轮);mode:"steer" 用于对方正在跑时的中途纠偏;mode:"inject" 只塞上下文不唤醒(对方要等"下一次有人用到它"那轮才读到);dryRun:true 只看会发给谁、不真发。wake 见「唤醒」一节。该域已暂停时不再硬投,而是落盘入队(reason:"domain-paused"、queued:true、可撤回),force:true 才显式放行。 |
claim_domain |
本会话认领一个域(写入域名册)。domain 可写短名 asr(项目取 project 参数,没给就取本会话 cwd 的目录名小写)或 misas:asr。已有人持有 ⇒ 不覆盖、报 conflict;force:true 才强制交接,旧负责人写进 note 留痕并收到一条 inject 停机通知(standDown)。交接 ≠ 解暂停:该域仍处已暂停就在回执里明说。 |
release_domain |
本会话释放它自己持有的域。不是自己在任的域不会被删。note 只回显、不落盘(CLI 的 release 无 note 字段)。释放=生命周期终结:插件会写一条墓碑(terminatedAt),派生状态变「已终结」。 |
list_domain_owners |
只读列出全部在任声明 + 每条声明的会话当前是否在线 + 派生状态;并带 domainState 段(五态字典 + 已终结墓碑 + 读不懂的状态/队列文件清单)。 |
create_domain_session |
开一个新的顶层会话并把开工任务排进去。domain + task 必填;cwd 缺省用调用方会话的 cwd;project 缺省用本会话 cwd 的目录名小写;claim:true 时建完把该域的在任声明指向新会话(已有别的负责人 ⇒ 不覆盖,回报冲突并给出换人办法);dryRun:true 只回报"将会建哪个 cwd 的会话、排什么任务",绝不真建。v0.3.2 起它还会把新会话挂进已存在的工作区分组并置顶到该组最上方(pin:false 可跳过);该域已暂停 ⇒ 直接拒绝(reason:"domain-paused",dryRun 也一样、宿主 create 一次都不调)。 |
set_domain_state |
(v0.4 新增;v0.4.2 推广到四级)把某一级(域 / 主 / 项目 / 全局)的政策轴设成「已暂停」或「活动」。scope 取 domain(默认)/ session(主=单会话)/ project / global;键:域=项目:域(仍要求已有在任声明)、会话=完整会话 id(短号只唯一命中时接受)、项目=项目名小写、全局=保留键 *(按/解都要 confirm:true)。state:"paused" = 按下(可逆);state:"active" = 人手动唤起:解开这一级后重评 OR——只重投「现在不再被任何一级压着」的 pending,仍被别的级压着的保持 pending 并报 drain.stillPausedBy(离线时允许尝试唤醒,见「恢复」一节)。note = 为什么按下。dryRun:true 只回报会怎么改。 |
withdraw_domain_message |
(v0.4 新增)撤回一条还在队列里的域间消息。id 就是 send_to_domain 被暂停拦下时回执里的 queue.id。默认只允许撤回自己发的(force:true 才动别人的);只有 pending 能撤(delivered/failed/withdrawn 一律回 not-pending,不假装收回);撤回后该条变 withdrawn 留在文件里当留痕(不删行)。 |
list_models |
(v0.6.0 新增,只读;v0.6.1 修目录来源与会话投影)列出宿主可路由的模型目录:首选 await ctx.sessionController.modelCatalog()(它是 async),groups 为空或形状不认识就退 ctx.llm.listProviders() / listModels(provider);source 如实写实际用了哪个(sessionController.modelCatalog / llm / empty)。两个来源都空 ⇒ 如实回 {ok:true, source:"empty", groups:[], rawKeys:[…], note:"catalog-empty"}(不假装有内容)。可选 session(会话 id / 域键)⇒ 额外读该会话的 modelSelection 投影,回执带 session:{target,lastUsed,pending,source};读不到就不给 session、改报 sessionRead:{ok:false,reason,tried,failures?}。 |
set_model |
(v0.6.0 新增)改一个会话的模型,下一轮请求生效。target 缺省=调用者自己,可写会话 id 或域键;执行顺序固定:记全局默认 → selectModel → 恢复全局默认,回执 {ok, sessionId, target, applied:"next-request", previous, globalDefault:{before, after, restored}}。宿主失败码原样带回;恢复失败如实写 restored:false + error。 |
set_default_model |
(v0.6.1 新增)显式改宿主的全局默认模型,且永不恢复(与 set_model 相反)。{provider, model, reasoningEffort?};实现=读 before → ctx.agentDefaultModel.saveSelection(next) → 读 after。影响面:之后新建的、且尚无会话级选择的 Agent;回执 {ok, before, after, source:"agentDefaultModel", note, saved, verified, applied}——宿主在没有部署配置入口/configEditor 时 saveSelection 是静默 no-op,所以读回来不一致就如实报 verified:false / applied:"not-observed",绝不假装成功。服务不可用 ⇒ api-unavailable,参数非法 ⇒ invalid-model。 |
两个轴 + 生命周期:v0.4 的状态模型
「暂停」不塞进「在线/离线」同一个枚举——它们是两条独立的轴,外加一条本来就有的生命周期:
| 轴 | 谁给 | 取值 | 存在哪 |
|---|---|---|---|
| 事实轴 | 宿主给,只读 | 在线 / 离线 = 有没有活 Agent | 不落盘,每次现算(ctx.agents.get()) |
| 政策轴 | 人给,可逆 | 活动 / 已暂停 = 字段 paused |
运行期主目录的 state 目录(见「落盘」) |
| 生命周期 | 本插件的 release_domain |
在用 / 已终结 | 同一个状态记录里的 terminatedAt 墓碑 |
对外一律给派生状态(派生、不落盘),就是五种:
| 派生状态 | 含义 |
|---|---|
在线·活动 |
有活 Agent,也没被按下(正常) |
在线·已暂停 |
有活 Agent,但某一级被按下(暂停不改在任身份,也拦不住已经活着的它,见「边界」)。⚠️ 这不等于"被禁言"——看 pausedByScopes[].via:是它自己/它的项目/全局被按才发不出去;只是"持有被按下的域"照样发得出去(见「四级」一节) |
离线·活动 |
没有活 Agent,政策上是活动的(窗口没开/重启后没加载) |
离线·已暂停 |
没有活 Agent,而且被按下了 |
已终结 |
已经 release_domain 退役(墓碑;域名册里那条声明已经被删) |
作用域(v0.4.2:四级全部实现):scope 可取 domain(默认,=域名册里的一个 key,如 tixiweihu:docs)/ session(=中文的「主(单会话暂停)」,键=完整会话 id)/ project(键=项目名小写)/ global(键=保留键 *)。四级互不覆盖,判定OR 叠加——详见下一节。
四级「已暂停」(v0.4.2):OR 叠加 + 两端都查 + 恢复重评
四级(机器面字面量,只有这四个):
scope |
键 | 中文说法 | 要不要有在任声明 |
|---|---|---|---|
domain |
项目:域(如 misas:asr) |
域 | 要(用户拍板的「承认现状」=域级暂停挂在声明册的一个 key 上;v0.4.0 语义一字不改) |
session |
完整会话 id(session-<uuid>;短号 sess-xxxxxxxx 只在该短号唯一命中一条已知会话时接受) |
主(单会话暂停) | 不要 |
project |
项目名小写(某会话 cwd 的目录名小写,与 claim_domain 短名补全同一约定) |
项目 | 不要 |
global |
保留键 *(唯一) |
全局 | 不要;按/解都要 confirm:true(缺 ⇒ 拒绝,reason:"global-needs-confirm",一个文件都不写) |
判定=OR 叠加(安全闸):任一级 paused:true ⇒ 拦。回执必须列出全部命中级别:
"pausedScopes": ["domain", "project"],
"pausedByScopes": [ { "scope": "domain", "key": "misas:asr", "via": "target-domain", "side": "target", "pausedAt": "…", "pausedBy": "session-…" },
{ "scope": "project", "key": "misas", "via": "target-project","side": "target", "pausedAt": "…", "pausedBy": "session-…" } ]
via 让人一眼看出是接收端还是发送端:接收端=target-domain / target-session / target-project / global;发送端=self-session / self-project / global(side 字段同时给 target / self / both / global);列表里由"它持有的域"命中的那条,via 写作 held-domain。
怎么读 在线·已暂停(很重要):它只说「跟这条会话相关的某一级被按下了」,不等于"它被禁言"。是否真发不出去,看 pausedByScopes[].via:
via |
含义 | 这条会话还发得出去吗 |
|---|---|---|
self-session |
它自己被 session 级按下 | ❌ 发不出去 |
self-project |
它的项目被 project 级按下 | ❌ 发不出去 |
global |
全局被按下 | ❌ 发不出去 |
held-domain(列表行)/ target-domain(拦截回执) |
它只是持有一个被按下的域(或目标就是那个域) | ✅ 照样发得出去——域级暂停只关那扇门 |
这正是「域级暂停不禁言整个会话」那条边界在列表上的样子;在线·已暂停 与 离线·已暂停 都按同一套读法。
两端都查:
| 端 | 查哪几级 |
|---|---|
| 接收端(target) | ① 目标域键 ② 目标会话 id(能解析出时) ③ 目标会话所属项目 ④ global |
| 发送端(本会话) | ① 本会话 id ② 本会话所属项目 ③ global |
| 刻意不含 | 「本会话自己持有的域被暂停」——域级暂停只关那扇门,不禁言整个会话(这条边界是为了让域级行为与 v0.4.1 逐字一致) |
降级不静默、也不误拦(S7):状态文件读不懂 ⇒ 当「没有这条记录」(不拦),但文件名进 unreadableStateFiles;级联里哪一步拿不到信息(如某会话 id 查不到 cwd ⇒ 推不出项目)⇒ 该级不拦,但回执标出 unresolved(写明哪一级没判成)。unresolved / unreadableStateFiles 只在非空时出现——所以"什么都没坏"的普通投递回执,键集仍与 v0.3.6 逐字一致。
F2 停机通知是唯一的例外(四级都一样):claim_domain(force:true) 给前任发的那条 inject 停机通知不走暂停闸,四级都不走——它是止损信,理由见下「边界」。
「已暂停」:三档拦截 + 落盘队列 + 可撤回
人在没有 GUI 的时候靠 set_domain_state(scope?, domain?, state:"paused", note?) 按下。按下的那一刻起,插件介导的路径分成三档:
| 路径 | 暂停期间的行为 |
|---|---|
send_to_domain |
不投递、也不替你找人顶上:消息落盘入队,回执 reason:"domain-paused"(冻结的历史字面量,四级都一样)、queued:true、queue.id(撤回要用它)、paused/derivedState/pausedAt/pausedBy/pauseNote/pausedScopes/pausedByScopes 全给(绝不笼统报 unreachable)。入队键=被投递目标自身的键:目标是域 ⇒ 域键;目标是会话 id ⇒ 会话 id。wake 也一并被拦(wakeBlocked:"domain-paused",连 probe 都不去拉活)。想硬投:force:true(回执带 pausedOverride:true,暂停本身不变;这道 force 不看拉活接线那道安全门)。 |
create_domain_session |
直接拒绝:created:false / reason:"domain-paused" / scope / derivedState / pausedScopes,宿主 create 一次都不调(dryRun:true 也一样,且不给 wouldCreate)。拦的是接收端(将新建的域 / 它的项目 / 它的负责人会话 / global)+发送端(本会话 / 本会话的项目 / global)。 |
claim_domain / release_domain |
照常允许(换人 / 退役 ≠ 解暂停),但回执明说「仍处已暂停(谁在何时按下的)」+全部命中级别+恢复办法。release 还会写下生命周期墓碑 ⇒ 派生状态变「已终结」,并在回执里给出终结那一刻还按着它的级别。 |
队列:<stateDir>/inbox/<safe-key>.jsonl,一行一条,status 走 pending → delivered / failed / withdrawn;mode 与信封正文在入队时冻结(恢复时按原 mode 原样重投,不会因为后来改了默认值而变形);记录里的 scope 是目标自身的 scope(域键 ⇒ domain、会话 id ⇒ session),被哪几级按下另存在 pausedByScopes(纯取证,不参与判定)。dsh 重启不丢(对比之下 mode:"inject" 的未读注入在退出时会被整体取消)。
撤回:withdraw_domain_message(id, domain?, force?) —— 默认只允许撤回自己发的(from 必须等于本会话 id,否则回 not-owner);force:true 才动别人的(回执 forced:true);只有 pending 能撤,撤完变 withdrawn 并留在文件里当留痕(不删行)。它管不了"人已经在对方窗口里看到的那条"。
状态文件读不懂就一律不写(state-file-unreadable,绝不覆盖别人的记录);读不懂的坏文件不拦投递(失败方向刻意选"当作活动"),但会在 list_* 的 domainState 里如实列出来(unreadableStateFiles / degradedQueueFiles)——降级不静默。
恢复(人手动唤起):重评 OR + 重投,以及 v0.4.1 之后的「尝试唤醒」
set_domain_state(scope?, domain?, state:"active") 是人的显式动作,它做两件事:把这一级的政策轴设回活动 + 重新评估 OR,只把「现在不再被任何一级压着」的 pending 按原 mode 重投(drain 段如实回报 delivered / failed / stillPending / stillPausedBy / statusWriteFailures)。
- 解一级不够:域恢复了、但项目 / 全局还压着 ⇒ 那条 pending 保持 pending,
drain.reason === "still-pressed"、drain.stillPausedBy给出还被谁压着。这正是"OR 叠加"的必然后果,也是 drain 的唯一正确性来源:一条 pending 能不能投,只看它目标当下的判定,与当初被哪一级拦下无关。 - 扫哪些队列只是优化:
global⇒ inbox 目录全量;project:P⇒P:*域键 + 属于项目 P 的会话键(判不出归属的进drain.skipped,不投、不乱投);session:sid⇒ 该会话键 + 它持有的域键;domain:k⇒ 该键。多条队列时drain.multi:true,delivered/failed/stillPending是聚合,逐队列明细在drain.queues。
目标当前没有活 Agent 时:
- v0.4.0:一律不碰——保留
pending并如实回报。(当时的理由是真机发现的 bug,见下节。) - v0.4.1 起:允许尝试唤醒(走
.agents.resume并把接线一起接好):唤得起来 ⇒ 按队列重投;唤不起来(没接线 / resume 抛错 / 拉起来也没 Agent)⇒ 消息原样保留pending并如实回报,结果就在drain.wake里(ok/reason/error/wakeWiring)。 - ⚠️ 只有人显式调
active才尝试唤醒:「暂停的域不会被别人自动叫醒」这条没变——别人发消息仍然只会入队。 - ⚠️ 队列里只要有一条要跑回合的(mode 不是
inject),唤醒就必须带接线,否则拒绝裸拉(见下节的安全门):这时drain.wake.reason === "no-wake-wiring"。
唤醒(wake)与 {{model}} 唤醒 bug 的修复(v0.4.1)
wake 的作用是"把一条存档里有、但当前没加载的会话拉活"(= GUI 里"点一下才活"那个动作)。只认完整 session id(短号 sess-xxxxxxxx 拉不活,回 no-wake-candidate)。
wake |
做什么 |
|---|---|
'' / 'false'(默认) |
不叫醒。离线就是离线(不排队,回 unreachable / declared-owner-offline)。 |
'probe' |
只拉活 + 回报:不投递任何消息、不跑任何一轮(真机验证用)。 |
'true' |
先拉活再投。投完发生什么取决于 mode:inject ⇒ 零成本入队(不跑回合);followup / steer ⇒ 真唤醒并跑一轮。 |
v0.4.1 修的那个 bug(真机,2026-10-13):插件以前调 ctx.agents.resume 时裸传 {resumeSessionId}——既没 agentOptions(provider/model),也没 setup。于是那个 Agent 的 options.model 是 undefined,而本部署的 deployment persona 段文本里写着 {{model}} ⇒ 第一次组装系统提示就抛错:prompt variable "{{model}}" has no value for this assembly (section "deployment:persona-prefix"),turn/start 后 100 ms 内 turn/end{kind:"error"}、零产出。宿主/GUI 那条路没事,因为它在 resume 之前先 composeAgent() 把这两样补上了。是插件漏传,不是核心不给(ResumeAgentOptions 本来就声明了 agentOptions? 和 setup?)。
现在拉活时:
- provider/model 分级取源(取不到就不传,绝不猜):① 宿主全局默认模型
ctx.agentDefaultModel.currentSelection()⇒ ② 该会话存档里最近一条request/header的config(走ctx.sessionQuery.readSession(id))⇒ ③ 都没有 ⇒ 空。 setupbest-effort:能挂就挂——装模型选择(复刻宿主的installModelSelection两条 waterfall:系统提示组装时补provider/model变量、请求路由时覆盖)+ 用ctx.get("agentPresets")resolve→mount把该会话的agentPreset挂回去。整段 try/catch,失败只记不抛(这一步炸了不会把 resume 一起炸掉)。- 回执如实报明用了哪条路(新增字段,
probe也带):
"wakeWiring": { "agentOptions": "ctx.agentDefaultModel | session-header | none",
"setup": "applied | skipped | failed", "setupError": null }
- 取不到接线时绝不裸拉(安全门):要真跑回合的档(
followup/steer,即mode≠inject)一律拒绝拉活,回reason:"no-wake-wiring"+ hint 指替代路(create_domain_session,或让人在 GUI 点开那条会话)。mode:"inject"(不跑回合)与wake:"probe"(只探测)不受影响。理由很直白:拉起来 100 ms 后崩,比明确不拉更糟——还会把那条会话变成"看起来在线、其实跑不动"。这道安全门不看force(force放行的是「暂停」这条政策,不是它)。同理,插件侧的第二个候选入口sessionController.resume(sessionId)收不下接线,所以只要这次要跑回合,走它也一律拒绝。
边界:插件只管「插件介导的」那条路
- 拦不住人:人自己在 GUI 点开那条会话、或宿主自己 resume——插件拦不住,那正是「人工手动唤起」,也是暂停语义里刻意留的口子。
- 「在线·已暂停」是真实的:暂停不改在任身份、也不会去关掉一个已经活着的会话;所以一个已经跑着的域会话在暂停期间照样能改文件。插件能做的只是"不再往里递新活"。
- 停机通知是例外,故意不拦(v0.4.2 对四级重新论证):
claim_domain(force:true)给前任发的那条 inject 停机通知不走暂停闸——四级都不走。理由:① 前任是"单条会话",它被session级按下、或它的项目被按下、或全局被按下,都不改变「它已经不再是这个域的负责人」这个事实,那句话照样得送到;② 四级暂停都拦不住人/宿主已经让它活着在跑,所以"前任正握着这个域在改文件"这个最危险的场景,会不会发生与按了哪几级无关;③ 它只 inject、不跑模型 ⇒ 四级下也不会把任何一个被按下的级别"叫活"。拦掉它比放过它危险得多(这正是 v0.3.6 那起"双活会话并发覆盖"的成因)。 - 「已终结」的墓碑只有本插件写的才有:只有经本插件
release_domain退役的域才在状态目录里留terminatedAt;用ownerCli指向的外部 CLI 直接 release 的域不会留墓碑(不新造台账)。
落盘:stateDir 与两个状态文件的格式
政策轴 + 生命周期 + 队列落在运行期主目录的 state 目录(配置键 stateDir,默认 <home>/state/,不写进插件包、也不写进 _registry 树)。home = DSH_DOMAIN_BRIDGE_HOME,没设就是 <homedir>/.dsh/dsh-domain-bridge。这里不是第二本台账:在任身份仍然只有域名册一份,状态目录里没有任何「谁在任」的记录。
config:
stateDir: '<你的目录>' # 不给=<home>/state;写 '' 也退回默认
<stateDir>/domains/<safe-file>.json 状态记录(一个 (scope,key) 一个文件,原子替换:写 .tmp 再 rename)
{ scope:"domain|session|project|global", key:"misas:asr"|"session-<uuid>"|"misas"|"*",
paused:true, pausedAt, pausedBy, note, resumedAt, resumedBy,
terminatedAt, terminatedBy, updatedAt }
⚠️ 记录里的 scope 一律是**真实 scope**(v0.4.0 那两处硬编码 domain 已逐个改掉)
读不到 / 格式坏掉 ⇒ 写入中止(绝不覆盖),读取按"活动"降级但不静默
<stateDir>/inbox/<safe-file>.jsonl 队列(**一行一条** JSON,dsh 重启不丢)
{ id, at, from, key, scope:"domain|session", mode, content, replyTo, pausedByScopes:[…],
status:"pending|delivered|failed|withdrawn",
deliveredAt, deliveredBy, failedReason, failedAt, withdrawnAt, withdrawnBy }
⚠️ `scope` 是**目标自身的 scope**(域键 ⇒ domain、会话 id ⇒ session);队列**不按 scope 分片**
坏行只跳过并记账(degradedQueueFiles),好行照用;撤回只改 status、**不删行**
文件名=一 scope 一键一文件(v0.4.2):<safe-file> 的基座是「key 小写 → 把非 [a-z0-9_.-] 压成 _ → 截 60 字符 → 加 - + 该 key 的 sha1 前 8 位」。
- 域级沿用 v0.4.1 的
k-<flat>-<sha1>(历史状态文件不能因为升级而失联); - 其余三级加 scope 前缀:
session-k-…/project-k-…/global-k-…⇒ 同一个 key 字符串出现在两个 scope 下也结构上不可能互相覆盖;顺带把「记录里的 scope 与文件所属 scope 不一致」变成可检测的(写入时拒绝,reason:"scope-conflict")。
建会话:create_domain_session(v0.3 新增;v0.3.2 起会入组 + 置顶)
调的是宿主服务 ctx.sessionController.create(request),最小合法请求只要给 cwd:
| 字段 | 必填? | 证据 |
|---|---|---|
cwd |
可选(不给就用宿主默认 cwd) | dsh-api-session-controller/lib/index.js:694 const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd |
sessionId |
可选,不给时宿主自己生成 session-<uuid> |
同文件 :688 const sessionId = request.sessionId ?? brandString(\session-${randomUUID()}`)` |
agentPreset |
可选(不给=宿主的默认 preset) | 同文件 :697 第四个实参 / :363 presets.resolve(presetId) |
workspaceId |
可选,但与 cwd 互斥 |
同文件 :687 两者都出现就抛 gateway/bad-request |
因此本工具不自己造 sessionId(让宿主当唯一身份来源),请求就是 { cwd }(给了 agentPreset 才多一个键)。⚠️ 参数名是 agentPreset,没有 preset 这个字段。真实实现最终落到 ctx.agents.create({sessionId, meta:{cwd, agentPreset?}, ...})(同文件 :450-458)。
建完紧接着用与 send_to_domain 完全相同的一份 v4 用户消息(同一个构造函数,不复制第二份)把 task 排进新会话的 inbox 并唤醒它。抬头上写明"这是域会话的开工任务",页脚写明回话方式(send_to_domain,target = 创建者会话 id)。
建完怎么用:新会话带 running 状态出现在左侧列表(运行中的行免折叠),并且 v0.3.2 起它已经被挂进对应工作区分组 + 置顶到该组第 1 行。请人到左侧会话列表点开 sess-xxxxxxxx 那一条——本工具翻不了前台,原因见下节。
模型分派(v0.6.0 起,v0.6.1 修正):list_models / set_model / set_default_model
宿主给插件这几个相关能力(dsh 0.2.1-rc.2 读 asar 定案;set_model 与 agentDefaultModel 已真机验证,list_models 的目录来源与会话投影按下面的真接口改写):
ctx.sessionController.selectModel({sessionId, provider, model, reasoningEffort?}):改该会话模型,下一轮请求生效(正在跑的 step 不换)。失败码:session/not-found/session/writer-held/session/model-unavailable。ctx.sessionController.modelCatalog()是async(dsh-api-session-controller/lib/index.js:3121return buildModelCatalog(this.ctx);实现lib/types/catalog.js:10export async function buildModelCatalog(ctx, …))⇒ 必须await。返回{default, routableProviders, groups:[{id,name,models}], failures};它自己就是用ctx.llm.listProviders()(同步)+await ctx.llm.listModels(provider.id)+resolveModelInfo组装的,并且只保留有模型的 provider 分组。退路ctx.llm.listProviders()/listModels(provider)。- v0.6.1 修的真机 bug:v0.6.0 少了
await,拿到的是一个 Promise ⇒ 真机回执groups:[]、routableProviders:null(而default其实是退到全局默认读出来的),于是"列不出任何 provider/model"。现在await,且groups为空(或形状不认识)就自动退 llm 目录;两个来源都空时如实报空:{ok:true, source:"empty", groups:[], rawKeys:[…], note:"catalog-empty", attempts:[…]}(rawKeys是 modelCatalog 原始返回的键名,attempts是每个来源逐步的 state,专为下次诊断)。
- v0.6.1 修的真机 bug:v0.6.0 少了
ctx.agentDefaultModel.currentSelection()/saveSelection(next)(dsh-agent-default-model/lib/index.js):读/写全局默认模型。
| 工具 | 参数 | 做什么 |
|---|---|---|
list_models |
无,或 session(会话 id / 域键) |
只读列目录:{ok, source, default, routableProviders, groups:[{provider,name,models:[{id,name,reasoning?}]}], failures?, session?}。source 如实写实际来源(sessionController.modelCatalog / llm / empty)。给了 session 就读该会话的 modelSelection 投影,回执带 session:{target,matchedBy,lastUsed,pending,source};读不到就不给 session、改报 sessionRead:{ok:false,reason,tried,failures?}。两个目录来源都空 ⇒ {ok:true, source:"empty", groups:[], rawKeys:[…], note:"catalog-empty"}。 |
set_model |
{target?, provider, model, reasoningEffort?} |
改一个会话的模型。target 缺省=调用者自己(拿不到调用者会话就报 target-unknown,要求显式传);可写会话 id(session-… / 短号 sess-…)或域键(项目:域 / 短名 ⇒ 走域名册解析成负责人会话)。reasoningEffort 传了才一起传(不发明默认值)。 |
set_default_model |
{provider, model, reasoningEffort?} |
改宿主的全局默认模型(agentDefaultModel.saveSelection),永不恢复。影响面=之后新建的、且尚无会话级选择的 Agent;改正在跑的会话请用 set_model。 |
session 那一半是怎么读的(v0.6.1:换成真接口)
list_models 给 session 时,按"够得着"的顺序读两个真接口:
ctx.sessionController.projections({sessionId}, signal)(dsh-api-session-controller/lib/index.js:3280,@Remote('projections')在:2698)⇒{kind:'sequenced', asOfSeq, values},其中values.modelSelection是wire 视图{lastUsed, next}(next= 宿主内部的pending ?? lastUsed;见lib/types/model-selection-projection.js:42-52)。它吃会话 id,所以活会话和已存档会话都能读(内部先看活会话,再退sessionQuery.observeSession)。- 退路:拿活 Session 对象(
ctx.sessions.get(id)——@deepseek-ai/dsh-session/lib/index.js:1739/1980注册的服务,返回store.get(id)?.session;或ctx.agents.get(id).session——宿主自己就这么用,dsh-api-session-controller/lib/index.js:366)再喂给ctx.sessionProjections.stateOf(session, 'modelSelection')(dsh-session-projection/lib/types/index.js:132)⇒ 宿主状态{lastUsed, pending}。
- v0.6.1 修的真机 bug:v0.6.0 把会话 id 字符串直接喂给了
stateOf。stateOf(session, key)内部要用session.header/session.snapshotEvents()(活会话/存档会话的日志折叠),字符串进去必抛 TypeError ⇒ 被兜底吞掉 ⇒ 真机回执里session字段整个缺失。现在 id 走接口 ①,接口 ② 只喂 Session 对象。 - 读法 ① 的
pending是宿主 wire 视图的next(=pending ?? lastUsed);读法 ② 的pending是宿主原生的pending。回执里的session.source会写明用的是哪一个。 - 两条路都读不到 ⇒ 不再静默省略:回执改给
sessionRead:{target, matchedBy?, ok:false, reason, tried:[…], failures:[{source, reason}]},把"试了哪几种、为什么不成"摆到台面上。
set_model 的回执形状
{ "ok": true, "sessionId": "session-…", "target": "misas:asr",
"matchedBy": "declaration", "applied": "next-request",
"previous": { "provider": "commandcode", "model": "deepseek/deepseek-v4-flash", "reasoningEffort": "off" },
"globalDefault": { "before": { … }, "after": { … }, "restored": true } }
set_default_model 的回执形状(不恢复,与 set_model 相反)
{ "ok": true,
"before": { "provider": "commandcode", "model": "deepseek/deepseek-v4-flash", "reasoningEffort": "off" },
"after": { "provider": "commandcode", "model": "deepseek/deepseek-v4-flash", "reasoningEffort": "high" },
"saved": { "provider": "commandcode", "model": "deepseek/deepseek-v4-flash", "reasoningEffort": "high" },
"source": "agentDefaultModel",
"note": "影响之后新建的、且尚无会话级选择的 Agent;改正在跑的会话要用 set_model",
"verified": true, "applied": "global-default" }
⚠️ "调成功"不等于"真写进去了":宿主的 saveSelection 在没有部署配置入口(fiber.entry === undefined)或没有 configEditor 时是静默 no-op——返回 fulfilled undefined,什么也没改。所以本工具写完会把 currentSelection() 读回来逐字比对:一致 ⇒ verified:true / applied:"global-default";不一致 ⇒ verified:false / applied:"not-observed"、after 如实是旧值、hint 明说"不能确认真的写进去了";完全读不回(没有 currentSelection)⇒ applied:"readback-unavailable"。三种情况都不抛错。
⚠️ 改会话会顺手改全局默认——set_model 会自动恢复
selectModel 的实现会在后台把这个选择写成全局默认(saveSelection)。所以本插件对每一次模型改动都固定三步:
- 先读
ctx.agentDefaultModel.currentSelection()(失败就记 null); - 调
selectModel(...); - 把全局默认恢复成刚记下的值(
saveSelection(prev),仅当 prev 为真且与这次选择不同)。
回执里的 globalDefault:{before, after, restored} 如实说明这件事:恢复失败也照写 restored:false + error,绝不假装成功;applied:"next-request" 表示正在跑的这一 step 不会换模型,下一轮才生效。宿主没有 selectModel ⇒ {ok:false, reason:"api-unavailable"},不抛错。要"改完就留着"(改全局默认)请用 set_default_model,它绝不会写回旧值。
create_domain_session 建完铺模型
宿主的 create() 不接受模型参数,所以只能「建完再改」。create_domain_session 新增可选 model:{provider, model, reasoningEffort?}:
- 传了
model⇒ 建成功后立刻对新会话调一次selectModel(同一套「记 → 改 → 恢复」),回执多一个modelApplied:{ok, provider, model, source:"explicit", error?}; - 没传 ⇒ 读配置
domainSessionModel(source:"config"); - 两者都没有 ⇒ 保持旧行为(继承全局默认),回执里不出现
modelApplied; - 建会话本身失败时一个模型都不改(
create之前不碰模型)。
配置示例(部署配置或 <home>/config.json 都行;非法/缺字段一律当"没有",永不抛):
config:
domainSessionModel:
provider: commandcode
model: deepseek/deepseek-v4-flash
reasoningEffort: off # 可选
{ "domainSessionModel": { "provider": "commandcode", "model": "deepseek/deepseek-v4-flash", "reasoningEffort": "off" } }
限制:子代理没有独立模型
子代理(subagent)不持有独立模型选择,它继承父会话的实时路由——插件侧改不了它,set_model 只作用于顶层会话的会话级选择。这是宿主模型,不是本插件的缺口。
v0.3.2:入组 + 置顶(让新会话在侧栏抬眼可见)
为什么要加(真机追查:projectlog/2026-10-11-列表可见性-追查.md):create({cwd}) 只给 cwd 时宿主不会把会话挂进任何工作区(dsh-api-session-controller/lib/index.js:694-708 只在给了 workspaceId 时才调 workspace.attachSession())⇒ 它掉进侧栏「未分组」桶、在手工排序里被追加到已保存顺序的最末(本机实测该桶有 732 条);而每个分组对空闲行只渲染 5 条、其余折进「更多」(dsh-client-ui-workspace/lib/client.js:2119-2134)⇒ 会话真的建出来了、真的在跑,人在侧栏里找不到。
v0.3.2 的两步(都在宿主侧;零新增顶层 import):
| 步 | 调用 | 依据 | 失败 ⇒ 降级 |
|---|---|---|---|
| ① 入组 | ctx.get('workspaceRegistry') → resolveByPath(cwd)(纯读,绝不 create:那会凭空多一个分组并改动用户的分组顺序)→ 命中就传 {workspaceId} 而不是 {cwd} |
dsh-tool-cordis/lib/types/api-catalog.js:3616(key)/ :3681(resolveByPath);dsh-workspace/lib/index.js:635-638;create 的 workspaceId/cwd 互斥在 dsh-api-session-controller/lib/index.js:687,传 workspaceId 时 cwd 取自 workspace.path(:694)⇒ attach 的 realpath 校验必然通过(dsh-workspace/lib/index.js:111-129) |
注册表拿不到 / 没命中 / resolve 抛错 ⇒ 退回 v0.3.1 的 {cwd}(请求逐字相同);workspaceId 版 create 抛错(如 workspace/not-found)⇒ 自动退回 {cwd} 重试一次,再失败才 {created:false, reason:"create-failed"} |
| ② 置顶 | registry.pinSession(sessionId) |
dsh-workspace/lib/index.js:576-587(幂等、Promise<void>、写 ~/.dsh/storages/workspace.json 的 global.pinnedSessionIds、不要求会话已 attach);写盘后 dsh-api-workspace-controller 推 {type:'pinned'} 给已打开的侧栏 ⇒ 即时生效,不用刷新 |
拿不到注册表 / 抛错 ⇒ pinned:{ok:false, reason} 如实回报,不影响 created:true;兜底提示链:那个分组若折叠就先点组名展开(折叠组一行都不渲染),再不行用侧栏搜索框输 cwd 的目录名(无标题会话按 cwd 尾目录名也能命中),或把排序方式切成「最近更新」 |
⚠️ create 失败 ≠ attach 失败(本版最关键的区分)
宿主 create() 先建会话、后 attach(dsh-api-session-controller/lib/index.js:697 → :701-708),attach 失败抛 RemoteError("session/workspace-attach-failed", ..., {sessionId, workspaceId})——这时会话已经存在。所以本插件:
- 认出这个标记就绝不重建(重建会多出第二条孤儿会话):从
error.details.sessionId取回 id(退一步从错误文本session "<id>" was created解析,再退一步如实报{created:true, reason:"created-but-workspace-attach-failed"}且不置顶),按「已创建但未入组」如实回报,并继续走置顶(pin 不要求已 attach,所以它照样能被顶到「未分组」最上方)。 - 严格按
error.code判别(不用 instanceof,跨 realm 会失效;dsh-typert-protocol/lib/types/remote-error.js:7明说"Discrimination is always bycode")。 - 其它错误(如
workspace/not-found,此时什么都没建出来)才允许走「退回{cwd}重试一次」。
返回体新增
workspace(lookup / lookupError / id / title / requested / attached / createFallback / createFallbackError / attachError——每个键恒在,值为 null/布尔,绝不含 undefined)、pinned({ok, reason})、session.workspaceId / session.workspaceTitle / session.attachedToWorkspace,以及 dryRun:true 时的 wouldPin。
lookup 取值:resolved-by-path / resolved-from-list / no-workspace-for-path / resolve-failed / workspaceRegistry-unavailable。
降级链(一句话)
拿不到 workspaceRegistry ⇒ 行为与 v0.3.1 完全一致(请求逐字相同,只多一段 pinned.reason:"workspaceRegistry-unavailable" 的提示);置顶不可用 ⇒ 提示搜索框 / 切「最近更新」排序;attach 失败 ⇒ 不重建,如实说"已创建但未入组"。
代价(用户可见)
每建一个域会话就多一条置顶行,并且该会话被挂进对应分组(都写同一个 workspace.json,不改浏览器本地的手工顺序,也不会重排别的行)。不想多这条行就传 pin:false;撤销置顶由用户点该行「…」→「取消置顶」(或宿主 unpinSession)。若某个分组是折叠的,置顶了也一行都不渲染——宿主侧没有任何通道能改客户端本地的折叠状态,所以只能在回复里点名分组并让人点一下展开。
为什么不能自动翻到前台(无 Host→Renderer 通道,人点一下)
- "出现"可以自动:
session/created→api-session/added被白名单转发到渲染层,侧边栏读全局useSessions,所以新行会自己冒出来(dsh-api-session-controller/lib/index.js:2873-2875→dsh-api-remotes/lib/index.js:31-33→lib/client.js:3615)。 - "切到前台"不行:三处外部可编程入口全都没有导航语义——① Remote 方法表 21 项里没有 open/select/reveal/focus;② 被转发事件白名单里没有任何导航事件;③ 桌面 IPC 全表里没有"打开某会话"。
- 不是权限问题,是架构上没有这条路:宿主跑在独立子进程(拿不到 Electron /
BrowserWindow),渲染层"当前选中会话"是浏览器本地存储(dsh.sessions.current),宿主够不着。能开前台视图的只有渲染层自己:人点一下,或一个dsh.client: { platform: 'web' }客户端插件订阅api-session/added后调ctx.uiWorkspace.openSession(id)。 - 所以本工具把话说明白:列表里可点 + 已在跑,由人点开那一条接上。
域解析顺序(v0.2 新增「声明优先」)
send_to_domain(target) 的解析顺序:
| # | 情形 | 结果 |
|---|---|---|
| ① | target 命中某个会话 id(长/short/包含) |
投递,matchedBy: "session-id"(行为与 v0.1 一致,未改) |
| ② | target 命中某域的在任声明,且声明会话在线 |
投给声明人,matchedBy: "declaration" |
| ③ | 命中声明但声明会话不在线 | 不投递,reason: "declared-owner-offline",回报 declaredOwner(sessionId/key/note/since/updatedAt)+ otherOnlineWithIndexLabel(索引标注恰好是这个域、当前在线的会话),由调用者决定 |
| ④ | 该域没有声明 | 退回索引标注逻辑,matchedBy: "index-label",并在回执里加提示「该域无在任声明,本次按索引标注解析,建议补声明」 |
| ⑤ | 索引标注也不止一个在线 | 保持 v0.1 口径:报警(multiple-online-for-domain),不擅自挑一个 |
| ⑥ | 同名短名命中多条声明(如两个项目都叫 asr) |
不猜:ambiguous-declarations,请用「项目:域」完整写法 |
配置(cordis.patch.yml + 每台机器的 config.json)
插件包里没有任何本机绝对路径:所有路径在加载时解析,前一层非空即胜出。
| 键 | 1. 部署配置(cordis.patch.yml) |
2. <home>/config.json |
3. 环境变量 | 4. 默认 |
|---|---|---|---|---|
indexFile(只读:会话 id → 域 标注) |
indexFile: |
indexFile |
DSH_DOMAIN_BRIDGE_INDEX |
<home>/registry.json |
domainsFile(在任声明册) |
domainsFile: |
domainsFile |
DSH_DOMAIN_BRIDGE_DOMAINS |
<home>/domains.json |
ownerCli(外部 CLI 的写法提示,可空) |
ownerCli: |
ownerCli |
DSH_DOMAIN_BRIDGE_OWNER_CLI |
空 ⇒ 文案只写裸脚本名 domain-owner.mjs |
domainSessionModel(v0.6.0:新建域会话建完铺的模型) |
domainSessionModel: |
domainSessionModel |
—(无环境变量) | 空/非法 ⇒ 不铺,继承全局默认 |
home = DSH_DOMAIN_BRIDGE_HOME,没设就是 <homedir>/.dsh/dsh-domain-bridge。要在一台机器上钉死路径,写 <home>/config.json 就行,不用改 cordis.patch.yml(可选;JSON 里反斜杠要写两个):
{ "indexFile": "<你的索引>.json", "domainsFile": "<你的域名册>.json", "ownerCli": "<你的路径>/domain-owner.mjs" }
config:
stateDir: '<你的目录>' # v0.4.0:政策轴「已暂停」+ 落盘队列;不给=<home>/state,写 '' 也退回默认
domainSessionPreset: 'workspace-write' # v0.3.3:create_domain_session 建完设的权限档;v0.6.4 起缺省=workspace-write,要全权自己写 danger-full-access
indexFile / domainsFile / stateDir 读不到或格式坏掉一律静默降级(当成"没有域标注 / 没有人认领 / 没有暂停"),不抛错、不影响投递;domainSessionPreset 写 '' / none 即关掉那一步(保持宿主默认档)。<home> 全空(没有 config、两个默认文件也不存在)时插件照常加载,只是报「0 个域」。
在别的机器上安装
- 把包目录放到任意位置,
dsh plugin --profile <profile> add link:<这个目录>(或从 npm 装);dsh 会应用包内的cordis.patch.yml。 - 可选:写
<home>/config.json(字段indexFile/domainsFile/ownerCli);不写就用<home>/registry.json与<home>/domains.json——home是空的也能正常加载,报「0 个域」而不是报错。 - 重启 dsh(或重开窗口)。运行期数据(暂停记录 + 队列)自动落在
<home>/state;插件包目录里的state/不再被读取(一次性复制迁移已删除——包内早已没有旧版本留下的数据)。
与 domain-owner.mjs 的互通
claim_domain / release_domain 与 ownerCli 指向的 domain-owner.mjs(claim / release / list / check)写同一份文件、同一 schema、同一冲突语义、同一原子替换写法(写 <file>.tmp 再 rename,绝不先删原文件;写失败自己清理 tmp)。两边互相读得懂、看得见。
- 字段:
{schemaVersion, updatedAt, owners:{ "<项目>:<域>": {project, domain, sessionId, since, by, note} }} - 文件不存在=正常状态("还没人认领"),任何读者都不当成错误
- 文件存在但 JSON 坏掉 ⇒ 写操作中止(避免覆盖别人的声明),读操作静默降级
- 插件端的
by与 CLI 不给--by时一致为null;要留痕请用note
事实依据(不是猜,逐条来自 dsh 0.2.0-rc.2 / 0.2.1-rc.2 源码或 asar 里的发行代码)
ctx.agents.list()/get(id)返回裸 Agent,无归属过滤 ——dsh-agent/lib/index.js:594get(id){ return this.store.get(id)?.agent }- 两个
roots()/list()是全局注册表,isOwnedBy判的是运行时创建者,与持久化谱系无关 steer/followup/inject在 loop 里零授权检查 ——dsh-agent-loop/lib/index.js:809steer(input){ this.send(input,'next-step',true) }- 门禁只有两处,都不拦顶层会话:①
dsh-subagent自己的邻接门(只治send_message通道)②dsh-api-session-controller对 subagent 会话的路由门 - 官方自己就有先例:
dsh-experimental-agent-team/lib/types/mailbox.js:204/216用ctx.agents.get(targetId)+ 裸steer - 窗口关闭 ⇒
get()返回undefined⇒ 天然满足"离线不排队、报不可达",不需要自己造死亡语义 ctx.sessionController是宿主侧普通 Cordis 服务,并被写进插件可见的 ctx API 目录 ——dsh-tool-cordis/lib/types/api-catalog.js:1838key: 'sessionController';create(request)的公开转发在dsh-api-session-controller/lib/index.js:2971,真实实现在:686-714- 新会话一定会宣告
session/created(dsh-session/lib/index.js:1782-1799)⇒ 自动推进 GUI 列表 ctx.workspaceRegistry也是宿主侧普通 Cordis 服务,同样写进插件可见目录 ——dsh-tool-cordis/lib/types/api-catalog.js:3616key: 'workspaceRegistry'(与:1838的sessionController同一张表);同类先例是同一 profile 的第三方插件dsh-mnemon/lib/index.js:10142-10147:const current = () => ctx.get("workspaceRegistry")(可选 + 调用期取 + 拿不到不抛)workspaceRegistry.pinSession(sessionId): Promise<void>定义在dsh-workspace/lib/index.js:576-587(幂等;已归档抛WorkspaceArchivedSessionPinError、会话不存在抛WorkspaceUnknownSessionError;不要求已 attach);resolveByPath(path)定义在:635-638(纯读:路径不存在时realpath直接 reject,目录没归属则返回undefined)create({workspaceId})时宿主调workspace.attachSession(sessionId)(dsh-api-session-controller/lib/index.js:701-708),失败抛RemoteError("session/workspace-attach-failed", ..., {sessionId, workspaceId});RemoteError的判别字段是code/details(dsh-typert-protocol/lib/types/remote-error.js:9-26,lib/index.js:2971的公开create是直通转发,错误原样穿透)- v0.4.1 拉活接线(
{{model}}唤醒 bug):ResumeAgentOptions本来就声明了agentOptions?与setup?(dsh-tool-cordis/lib/types/api-catalog.js:6191-6192,AgentOptions在:4412、AgentSetup在:4440);宿主自己的 resume 就是这么调的 ——resumeObserved里agentOptions: this.agentOptions()+setup: composition.setup(dsh-api-session-controller/lib/index.js:399-411),agentOptions()取自ctx.agentDefaultModel.currentSelection()(:460-466),composeAgent()的 setup 装模型选择 +presets.mount(:358-371);{{model}}变量由ctx.systemPrompt.variable("model", (context) => context.agent?.options.model)注册(dsh-agent-loop/lib/index.js:1565),而报错点确认"变量已注册但这一轮值是undefined"(dsh-system-prompt/lib/index.js:167-172);setup里装的那两条 waterfall 是system-prompt/assemble覆盖provider/model变量 +agent/request覆盖路由(dsh-agent/lib/index.js:166-192,本插件用公开的agentCtx.on复刻,不 import 核心内部) - 存档读取口径:
ctx.sessionQuery.readSession(id)返回{session, inheritedEventCount, events}(dsh-session-query/lib/index.js:1079-1087);request/header事件由 agent-loop 以session.append("request/header", {header, reason})写下(dsh-agent-loop/lib/index.js:1214-1228),消费端就从event.data.header.config取 provider/model(宿主的modelSelection投影:dsh-api-session-controller/lib/index.js:2077-2082);SessionHeader.agentPreset?: string是公开可选字段(dsh-agent-preset-registry/lib/typert.host.js:655) - v0.6.1 模型分派(dsh 0.2.1-rc.2,全部读 asar 的发行代码):
modelCatalog()返回 Promise ——dsh-api-session-controller/lib/index.js:3121modelCatalog(){ return buildModelCatalog(this.ctx) };lib/types/catalog.js:10export async function buildModelCatalog(ctx, defaultSelection = ctx.agentDefaultModel.currentSelection()),内部:11ctx.llm.listProviders()(同步)、:14await ctx.llm.listModels(provider.id)、:16await ctx.llm.resolveModelInfo(...),:52-53groups = …filter(group => group.models.length > 0)、:56routableProviders = groups.map(group => group.id)、:58failures。v0.6.0 漏了await,所以真机groups:[]/routableProviders:null。- 按 id 读投影的真接口:
dsh-api-session-controller/lib/index.js:3280async projections(request, signal)(装饰器@Remote("projections")在:2698);:3281const { sessionId } = request;:3291先试活会话liveProjections(sessionId)(:3322-3330,用this.ctx.sessions.get(sessionId)+sessionProjections.snapshot(session));:3293再试sessionPersistence.stat;:3302最后sessionQuery.observeSession(sessionId)⇒ 返回{kind:'sequenced', asOfSeq, values}/{kind:'migration-required', values}/null。必须有 signal(:3290signal.throwIfAborted())。 sessionProjections.stateOf(session, key)(dsh-session-projection/lib/types/index.js:132,实现lib/index.js:126)只认 Session 对象::136 materializeCells(session)→lib/index.js:377-383 cellFor要用session.header/session.inheritedEventCount/session.snapshotEvents();传 id 字符串必抛。注册处dsh-api-session-controller/lib/index.js:2198ctx.sessionProjections.register(modelSelectionProjection)。modelSelection投影的形状:dsh-api-session-controller/lib/types/model-selection-projection.js:45init: () => ({lastUsed: null, pending: null})、:47-50wire.view: state => ({lastUsed, next: state.pending ?? state.lastUsed})。sessions服务=dsh-session/lib/index.js:1739super(ctx, "sessions")、:1980-1982get(id){ return this.store.get(id)?.session }(活会话才有);agents服务=dsh-agent/lib/index.js:594get(id){ return this.store.get(id)?.agent };宿主自己读会话就是这样取的(dsh-api-session-controller/lib/index.js:366/:3323)。- 全局默认:
dsh-agent-default-model/lib/index.js的agentDefaultModel服务 ——currentSelection()读 Config 引用、async saveSelection(next)里:entry === undefined直接 return、ctx.get("configEditor") === undefined也直接 return ⇒ 静默 no-op(这就是set_default_model必须读回校验的原因)。
技术栈(选型结论)
- 形态:静态 Cordis 插件包(
export default { name, inject, apply }) - 语言/构建:纯 JS ESM,零构建(无 tsdown/tsconfig)
- 依赖:只
node:fs/node:path/node:crypto内置;inject: ['tools','agents'](sessionController、workspaceRegistry、agentDefaultModel、sessionQuery、agentPresets都故意不写进 inject:本部署没挂它们时必须还能加载、如实回报{created:false, reason:"sessionController-unavailable"}/pinned:{ok:false,reason:"workspaceRegistry-unavailable"}/wakeWiring:{agentOptions:"none",...},而不是让加载器硬失败;都用ctx.get(...)可选取用。v0.4.1 也没有新增任何顶层 import——setup里那条"装模型选择"是靠公开的agentCtx.on(...)复刻宿主语义,而不是 import 核心内部。静态包里新增 import 一旦解析失败是在模块加载期抛,apply的 try/catch 兜不住) - 安装:
link:本地安装,不上 npm、不进 git - 为什么:这套路径本机已跑通 6 次(
~/.dsh/dsh-plugins),踩平了最致命的坑——静态包里没有harness全局,且apply抛错会让加载器硬失败(AggregateError: loader entries failed to apply,宿主起不来)。因此本插件的apply永不抛错,工具注册走"harness 优先 +ctx.tools.register兜底 + try/catch"两 realm 兼容写法。
硬边界
- 不建第二本台账:真值仍是使命库 + 索引;域标注只读索引(
indexFile)与在任声明册(domainsFile),读不到也不影响投递。写只发生在声明册,且与domain-owner.mjs逐字段一致。v0.4 新增的stateDir里只有政策轴 + 生命周期 + 队列,没有任何"谁在任"的记录。 - 不做网状直连:只做"主会话 ↔ 域会话"这一段;跨域仍应经主会话。
- 离线默认不排队(行为没变):窗口不在线就该报不可达;唯一例外是「该域已暂停」——那时消息落盘入队(可撤回、可重投),因为那是人显式按下的政策,不是"发不出去"。
- 绝不替调用者挑人:声明会话离线、同域多在线、同名域多条声明——一律如实报警并把候选列出来。
- 绝不裸拉活(v0.4.1):没有 provider/model 接线时,要跑回合的唤醒一律拒绝;探路用
wake:"probe"。
返回契约(v0.3.1 起,硬契约)
- 所有工具输出保证无损 JSON:任何位置都不含
undefined(也不含NaN/±Infinity/-0/bigint/Date/Map/Set/函数/循环引用)。宿主按"JSON.parse(JSON.stringify(v))必须与原值相等"来校验;JSON.stringify会静默丢掉undefined键,往返即有损 ⇒ 宿主整次拒收,报tool "<名字>" returned invalid output: value is not lossless JSON,且不指向具体字段。 - 实现:
lib/bridge.js里的pruneUndefined()在tool()工厂统一过一遍(一个卡点覆盖 11 个工具的全部返回路径,不逐分支打补丁):删掉undefined/函数/symbol 的键(数组里换成null),并归一NaN/±Infinity→null、-0→0、bigint→十进制字符串、Date→ISO 字符串、Map/Set→普通对象/数组、循环引用→null。 - 回归护栏:
node test/smoke.mjs(或npm test)对 11 个工具每条可达分支做深度扫描 + 无损往返断言,并冻结 envelope 文案、list_domain_sessions顶层键集与VERSION == package.json version。新增分支请一并补例,别只断言某一个字段。 - v0.3.2 新增第 7 节:入组 + 置顶的整条阶梯都用假 registry +
create()探针驱动(不真建会话、不真调pinSession、不碰真workspace.json):命中/未命中、workspaceId失败自动重试、两次都失败、attach 失败不重建(含只有错误文本 / 完全拿不到 id 两种退化)、pinSession抛错、pin:false不调 pin、拿不到 registry 时请求与 v0.3.1 逐字相同,以及list()兜底 / resolve 抛错 / 没有pinSession/ 经ctx.get取值 /ctx.get抛错回落属性读取。 - v0.4.0 新增第 11 节:域级「已暂停」的整条政策轴(按下/幂等/非法 state/没声明/短名解析、投递拦截与落盘队列、
dryRun、按会话 id 直投也拦、create_domain_session拒绝、wake被拦与force放行、claim/release照常允许、撤回的四种结局、恢复重投的四条路、五种派生状态一次列全、未暂停域的键集逐字回归、状态文件坏掉/写不动/队列坏行的降级)。全部跑在每个 mount 自己的临时stateDir,绝不碰真实的运行期state/。 - v0.4.1 新增第 12 节:
{{model}}唤醒 bug 的修复——每条用例都让真的 resume 调用穿过一个"像宿主一样真的会回调setup"的假 agents 服务:取源①ctx.agentDefaultModel、取源② 存档里那条request/header、存档读不懂时降级成none且绝不猜、两条都空时拒绝裸拉(no-wake-wiring,一次 resume 都不调;probe/mode:"inject"放行、force也越不过)、setup抛错只记不抛(回执failed+setupError)、旧入口sessionController.resume带不了接线时一并拒绝,以及 F2 停机通知对已暂停的域照发(且不进队列)。 - v0.4.2 新增第 13 节(四级暂停):① 项目级压着域级 ⇒ 投递被拦且报出两级(
pausedScopes/pausedByScopes);② 只解域级 ⇒drain.reason:"still-pressed"+stillPausedBy,pending 保持;再解项目级才重投;③global缺confirm⇒global-needs-confirm且连状态目录都不建(一个文件不写),带confirm才生效,且全局压着时域目标/会话目标/create 全拦;④ 两端都查:本会话 session 级 / project 级被按 ⇒ 发不出去(0.4.1 完全没有这道门),而"自己持有的域被暂停"不禁言整个会话(刻意边界);⑤ session 键:完整 id 不要求在线、短号唯一命中才收、片段歧义 ⇒session-ambiguous、非法scope(含master)⇒invalid-scope;⑥ 降级不静默:坏状态文件 ⇒ 投得出去但回执带unreadableStateFiles,推不出项目 ⇒ 不拦但带unresolved;⑦ 列表:项目级按着 ⇒ 该项目里所有在线会话显示在线·已暂停,session 级只标那一条,domainState.pausedRecords/unresolved如实列;⑧ drain 的驱动范围(session ⇒ 会话键 + 它持有的域键;project ⇒P:*+ 属于 P 的会话键;global ⇒ 全量);⑨ 向后兼容:不带scope⇒ domain 级、域级文件名与旧字段语义不变、未暂停时的投递回执键集与 v0.3.6 逐字一致。 - v0.6.0 新增第 14 节(模型分派,26 个分支用例):
list_models的 modelCatalog 正常/抛错退 llm/两者都没有、session 投影(会话 id 与域键两条路);set_model的调用顺序断言(currentSelection → selectModel → saveSelection,且saveSelection收到旧值)、session/writer-held原样回传但仍恢复全局默认、恢复失败如实restored:false+error、域键/短号 target 解析、api-unavailable时一次服务调用都没有且不抛、空参数/拿不到调用者会话、选的就是当前默认时不写回;create_domain_session的显式 model / 配置默认 /<home>/config.json三层来源、显式优先于配置、两者都没有时不调 selectModel、建会话失败时一个模型都不改、dryRun 只报告wouldSetModel。全部 mock 驱动,不真改会话、不真写全局默认。 - v0.6.1 新增第 15 节(首次真机的两个缺陷 + 全局那一半):假服务的
modelCatalog()改成 async(与宿主一致),另有一条故意返回裸 Promise ⇒ 少了await就会当场露馅(真机 bug 回归);groups空/形状认识但空/provider 没有模型三条都退llm.listProviders()+listModels(),两个来源都空 ⇒{ok:true, source:'empty', groups:[], rawKeys:[…], note:'catalog-empty'}(含attempts逐步 state);listModels的字符串数组与{id,name,reasoning?}两种形状都归一;会话投影三条路都测——①sessionController.projections({sessionId}, signal)(断言请求形状、必须带 AbortSignal、wire 视图{lastUsed,next}的映射)②stateOf必须收到 Session 对象(断言asked[0][0] === agent.session,即 v0.6.0 真机必抛的那个点)③ 两条都失败 ⇒ 如实sessionRead:{ok:false,reason,tried,failures}(不再静默省略);set_default_model的正常改(saveSelection只准被调用一次、收到的必须是新值——"不恢复"的硬断言)、没传reasoningEffort就不写、服务不可用(ctx 上没有 / 只有currentSelection)⇒api-unavailable且零调用、参数非法 ⇒invalid-model且零调用、宿主保存抛错 ⇒ 原样回传错误码、静默 no-op 宿主 ⇒verified:false/applied:'not-observed'、读不回 ⇒readback-unavailable、读回抛错 ⇒beforeError/afterError但写入照旧。全部 mock 驱动,不真改会话、不真写全局默认。
生效方式
静态 Cordis 插件包,改动 lib/bridge.js / cordis.patch.yml 后**需要重启 dsh(或重开窗口)**才会被加载;插件未重启前,create_domain_session / claim_domain 等新工具在模型侧不存在(运行期效果——入组与置顶本身是即时的:宿主写盘后推给已打开的侧栏,不用刷新)。
重启后第一件该做的事(挑一个已存在、且当前是展开的工作区分组来做,验完方便清理):
create_domain_session(
domain: "probe:smoke",
task: "这是一次性探针:收到即回复「探针收到」,然后什么都不用做。",
cwd: "<你的路径>", # 换成你界面上某个已展开分组的目录
claim: false
)
期望看到:① 返回 created:true、workspace.attached:true、pinned.ok:true,workspace.id 就是该目录对应的工作区 id;② 界面上那个分组的最上方多出一行无标题新会话(不用刷新、不用切排序)——若那个分组本来是折叠的,先点一下组名展开;③ 它自己开始跑并回「探针收到」。找不到就用侧栏搜索框输 tixiweihu(或把排序切成「最近更新」)。验完把那条会话归档,别删文件。
回滚
dsh plugin --profile desktop remove dsh-domain-bridge
或把 ~/.dsh/profiles/desktop/package.json 里对应依赖与 dsh.profile.bundles 条目删掉。