liangmianya/dsh-synapse
liangmianya/dsh-synapseDeepSeek Harness 的可视化非线性对话工作区:把会话、追问与分支变成可浏览、可拖拽的对话地图。
长周期 agent 会话的域身份层——声明优先的域寻址、会话工厂、四级暂停与模型分派,用于协调多个顶层会话。
域可达层 v0.8.16(v0.7.0 起新增斜杠命令(v0.8.5 起七条:加了 /resume)、v0.8 起带弹窗菜单、v0.8.7 起换届可逐项设置;模型分派: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 一句话:插件包里不写任何机器路径、也不存运行期数据——`/config.json` 管本机路径,`/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`(显式改全局默认、永不恢复)。
现状(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;v0.8.16 起可省 `id`——只给 domain 就撤该队列最新一条 pending(默认只认自己发的,force:true 才看别人的)。默认只允许撤回自己发的;只有 pending 能撤(delivered/failed/withdrawn 一律回 not-pending,不假装收回);撤回后该条变 withdrawn 留在文件里当留痕(不删行)。 list_domain_queue · (v0.8.14 新增,只读)列出插件落盘队列里的消息:/inbox/.jsonl 里每条给 id / status / mode / from(+短号)/ at / 正文摘要。status 取 pending(默认)/ delivering / delivered / failed / withdrawn / all;domain 可写域短名、项目:域 或会话 id(短号也行);mine:true 只看本会话发的;limit 默认 50(上限 200)。另给 totals、每个队列一行(pending / total / lastAt)与 latestId / latestMineId(配 withdraw_domain_message 用)。边界:只管插件自己的队列——宿主 composer 里的「N 条排队消息」不在里面,插件看不见也撤不回。 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。
侧栏多一个「体系」入口,主区是一整页工作台:左=会话管理入口、右=会话协作空间(两者并列、可联动;从右栏能进拓扑图与三维网络)。客户端插件这一层只放一个 iframe,页面本体由本插件的 7 条白名单路由发出去——GUI 改版不用动插件,插件改内部也不用动页面(用户口径:保持轻耦合)。
路径 · 文件(config.spaceDir,缺省 /space) · 说明 GET /api/dsh-domain-bridge/hub · hub.html · 工作台本体 GET /api/dsh-domain-bridge/hub/view/network · network.html · 三维网络视图 GET /api/dsh-domain-bridge/hub/view/topology · topology.html · 拓扑视图 GET /api/dsh-domain-bridge/hub/view/space.data.js · space.data.js · 空间数据(生成期算好) GET /api/dsh-domain-bridge/hub/view/space.json · space.json · 同一份数据的 JSON GET /api/dsh-domain-bridge/hub/view/state.json · state.json · 可选的静态快照 GET /api/dsh-domain-bridge/hub/view/v9-tokens.css · v9-tokens.css · v9 视觉资产(token+卡片皮肤,必须 text/css)
hub.html 缺席时回一页可执行空态(写清放哪个目录、需要哪几个文件、怎么配 spaceDir);其它文件 404。rel=stylesheet 做严格 MIME 检查,发成 application/octet-stream 会整份样式被丢掉(实测 cssRules=0)。fetch('/api/dsh-domain-bridge/state')(同一个 webServer、同源),插件不替它取数——所以状态面怎么改,页面不用跟着改。fetch('/api/…') 在解析阶段就抛 Failed to parse URL。七条宿主命令,命令体只做编排(转调上面的工具定义,没有第二套实现、也不经过模型):
命令 · 作用 /register · 把本会话注册为体系主会话 / 项目主会话 / 域会话(domain [takeover] 转调 claim_domain);不带参数=只看当前身份与可选项 /initialize [check] · 生成 / 补全项目骨架:PROJECT.md、KNOWLEDGE.md、KNOWLEDGE、提案、projectlog、.gitignore 标准条目——只补缺失、绝不覆盖;带 check 只报告、一个字节都不写 /handoff [wizard] · 开一个接班会话并转移职责(默认参数表:体系 cordis / 项目 standard / 域 standard;模型与权限档取自部署配置)。wizard 打开本地分步表单逐项改参数;target= 把在任身份交给已经开着的会话(不新建、不唤醒);key= 只能在你真持有的身份里选;retire=now=建成功后当场退役自己 /retire · 结束自己的任职:撤注册 + 释放域 + session 级暂停 + 会话自我改名「(已退役)」;不删历史 /pause · 暂停自己 / 一条会话(session )/ 一个域 / 本项目 / 全局(按身份闸;判不出身份就拒绝);裸跑=列出当前所有暂停记录(只读) /resume · 反向开关:恢复自己 / 一条会话 / 一个域 / 本项目 / 全局(与 /pause 同一套授权);恢复时重新评估 OR,只重投不再被压着的排队消息 /pause-all · 紧急通道:强制暂停(必填原因;project/global 要在 60 秒内补 --confirm——服务端记待确认,超时或层级不符一律拒绝),写 stateDir/audit.jsonl
换届的逐项设置(v0.8.10 起:应用内面板):宿主弹窗壳选中一行后必定关闭、没有链式多级的入口(popupSelect 只有单层),所以 v0.8.10 改成 commandUi.decorate 的 action + 把面板渲染进根级 shell.overlay:五段手风琴(身份 / 模式 / 模型 / 权限档 / 标题与开工任务)+ 搜索 + 手填模型,选完自动收起,不再打开浏览器页面。数据与提交仍走 POST /api/dsh-domain-bridge/handoff-wizard/data|run(同一条 /handoff 实现,invocation.params 直传,不是第二套)。旧的本地面板页保留为兜底(面板右上「旧版网页设置」,覆盖层不可用或 600ms 内宿主没挂上时自动退回)。v0.8.11 起默认开工任务换成 219 字短模板 + 自动生成 ≤2KB 交接卡,接任者第一轮只读那张卡。
顺序约定:注册在前、初始化在后。 注册键就是 cwd 目录名,初始化的产出不参与推导——所以空目录(新建项目 / 新体系根)也能先注册,/initialize 随后把骨架建起来。只有 /register system 在目录里没有 SYSTEM-OVERVIEW.md 时要一次显式确认(--confirm):自封体系主会拿到 /pause global 权限,值得按一下。
弹窗菜单:浏览器半边(lib/client.js)用 commandUi.decorate 给这七条命令挂 popupSelect——输入 /、选中命令就能点选参数,不用背语法。菜单在宿主侧按真实状态算:每条命令的选项按分组列出(身份/体系层/项目层/域层/参数/紧急),需要再确认的动作走宿主原生确认浮层 ⇒ 人只用点,不用打字(唯一例外是"开一个全新的域"要给它一个名字)(POST /api/dsh-domain-bridge/{health,menu,run},仅本机可访问),危险动作(接管域、退役、强停、全盘写交接)带二次确认;选完走 ctx.commands.execute,回执作为命令流转节点留在会话里。
注册册:写在 _registry/domains.json 的平行节 registrations(owners 一字不动,域声明仍在 owners)。写盘全程持 wx 独占锁 + 唯一临时文件——固定名 .tmp 且无锁时,两进程并发各写 30 键最终只剩 2~12 个(静默丢写)。
「暂停」不塞进「在线/离线」同一个枚举——它们是两条独立的轴,外加一条本来就有的生命周期:
轴 · 谁给 · 取值 · 存在哪 事实轴 · 宿主给,只读 · 在线 / 离线 = 有没有活 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 叠加——详见下一节。
四级(机器面字面量,只有这四个):
scope · 键 · 中文说法 · 要不要有在任声明 domain · 项目:域(如 misas:asr) · 域 · 要(用户拍板的「承认现状」=域级暂停挂在声明册的一个 key 上;v0.4.0 语义一字不改) session · 完整会话 id(session-;短号 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 还会写下生命周期墓碑 ⇒ 派生状态变「已终结」,并在回执里给出终结那一刻还按着它的级别。
队列:/inbox/.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)——降级不静默。
set_domain_state(scope?, domain?, state:"active") 是人的显式动作,它做两件事:把这一级的政策轴设回活动 + 重新评估 OR,只把「现在不再被任何一级压着」的 pending 按原 mode 重投(drain 段如实回报 delivered / failed / stillPending / stillPausedBy / statusWriteFailures)。
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 时:
pending 并如实回报。(当时的理由是真机发现的 bug,见下节。).agents.resume 并把接线一起接好):唤得起来 ⇒ 按队列重投;唤不起来(没接线 / resume 抛错 / 拉起来也没 Agent)⇒ 消息原样保留 `pending` 并如实回报,结果就在 drain.wake 里(ok / reason / error / wakeWiring)。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?)。
现在拉活时:
ctx.agentDefaultModel.currentSelection() ⇒ ② 该会话存档里最近一条 request/header 的 config(走 ctx.sessionQuery.readSession(id))⇒ ③ 都没有 ⇒ 空。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) 收不下接线,所以只要这次要跑回合,走它也一律拒绝。claim_domain(force:true) 给前任发的那条 inject 停机通知不走暂停闸——四级都不走。理由:① 前任是"单条会话",它被 session 级按下、或它的项目被按下、或全局被按下,都不改变「它已经不再是这个域的负责人」这个事实,那句话照样得送到;② 四级暂停都拦不住人/宿主已经让它活着在跑,所以"前任正握着这个域在改文件"这个最危险的场景,会不会发生与按了哪几级无关;③ 它只 inject、不跑模型 ⇒ 四级下也不会把任何一个被按下的级别"叫活"。拦掉它比放过它危险得多(这正是 v0.3.6 那起"双活会话并发覆盖"的成因)。release_domain 退役的域才在状态目录里留 terminatedAt;用 ownerCli 指向的外部 CLI 直接 release 的域不会留墓碑(不新造台账)。stateDir 与两个状态文件的格式政策轴 + 生命周期 + 队列落在运行期主目录的 state 目录(配置键 stateDir,默认 /state/,不写进插件包、也不写进 `_registry` 树)。home = DSH_DOMAIN_BRIDGE_HOME,没设就是 /.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):` 的基座是「key 小写 → 把非 [a-z0-9_.-] 压成 _ → 截 60 字符 → 加 -` + 该 key 的 sha1 前 8 位」。
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- · 同文件 :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` 那一条——本工具翻不了前台,原因见下节。
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。dsh-api-session-controller/lib/index.js:3121 return buildModelCatalog(this.ctx);实现 lib/types/catalog.js:10 export 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)。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,专为下次诊断)。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 时,按"够得着"的顺序读两个真接口:
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)。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}。