dsbot-session-create
给 DSH 加四个工具:
create_session—— 在指定的 DSH 工作区、或任意一个已存在的文件夹里开一个新会话,并把一段提示词投进去。开之前先把模型和权限档定下来。session_message—— 给一个还在跑的会话插一条消息(插队投放,它下一步就读到)。read_session_transcript—— 按会话 id 把它说过的话和当前状态读回来。list_dispatched_sessions—— 看这个插件派过哪些会话、各自此刻什么状态。
它是一个 DSH 插件(dsh-plugin),宿主半边,没有界面。
三条与普通「开个会话」不同的硬规则,先看这里:
- 派出去的会话不落到宿主的全局默认模型上。它钉死到派活那个 Bot 自己正在用的模型;读不到派活人的模型就不建这一单。
- 缺省权限档是全权限。不传
permission时一律开在danger-full-access,并把这一条写进结果;要auto就显式传。 - 补话只服务还在跑的会话。目标会话已经停下,就拒投并让你新开一个,不往历史里一层一层堆活。
为什么需要它
DS Bot 的 Chief 能把活派给别的 Bot(message_bot),但派不进「某个项目的那个会话」。
宿主本身早就有这个能力(ctx.sessionController.create({ cwd | workspaceId })),只是没有对外开放成工具。
这个插件就是那层薄壳:把宿主已有的原语开成工具。
参考实现是 DS Bot 自己的 src/host/roster.js(帮 Bot 建会话时用的同一批调用)。
工具
create_session(title, prompt, workspace? | folder?, preset?, permission?, model?)
session_message(sessionId, message)
read_session_transcript(sessionId?, last?)
list_dispatched_sessions(limit?)
create_session
| 参数 | 必填 | 说明 |
|---|---|---|
title | 是 | 新会话的名字,显示在会话列表里 |
prompt | 是 | 投进新会话的那段话(排队投放,mode: 'queue') |
workspace | 二选一 | 工作区 id,或它的完整文件夹路径 |
folder | 二选一 | 任意一个存在的文件夹的完整路径 |
preset | 否 | agent 预设(standard / minimal / ptc / creator),建会话时写进 header,开跑后不能换;名字错就整单不建 |
permission | 否 | 权限档,见下面「权限档」 |
model | 否 | 写成 provider/model;不写就与派活的那个 Bot 一致 |
workspace 和 folder 必须给且只能给一个。
两者的区别:
workspace—— 会话挂在那个 DSH 工作区下,会出现在用户的对应项目里。folder—— 会话按cwd创建,不加入任何 DSH 工作区,用户的项目列表保持原样。
两者是同一套宿主原语的两种落位方式,没有别的差别。
模型(fail-closed)
宿主 sessionController.create 不转传 model,所以不钉就是「宿主的全局默认模型」;2026-11-01 那个默认一度是按量付费的模型,派出去的会话因此替用户烧过真钱。所以:
- 不写
model时,取派活那个 Bot 自己会话正在用的模型(agent.session.requestHeader().config的 provider / model / reasoningEffort)。 - 不写任何模型名做兜底:写死一个名字,provider 或组一改名,这条绑定就静默失效。
- 显式写
model必须是provider/model——按第一个斜杠切一刀、两边都要有字;provider空或model空就整单失败(model-invalid),model 本身允许再带斜杠(b/c)。 - 派活人的模型读不到就不建这一单(
dispatcher-model-unknown)。宁可失败,也不留一条会落到全局默认模型上的会话。 - 建会话之后用
sessionController.selectModel钉死。这一步失败不中断派送,但结果里带一句modelNote,明说「没钉上、可能落到宿主默认、花额度之前先查它的模型」。
权限档
档名由宿主判定(/permission 的合法值会随宿主升级漂移),本插件不硬编码白名单,只做三件事:
- 显式传
read-only/workspace-write会被改写成danger-full-access,并在结果里如实标出permissionOverriddenFrom。理由:这两档带approval: ask,派单会话无人应答就整轮卡死,而宿主没有「给已存在会话提权」的入口。要放行只读探针,在宿主环境里设DSBOT_ALLOW_RESTRICTIVE_PERMISSION=1(值必须严格等于1)。 - 没传
permission时一律开在danger-full-access,并在结果里带permissionNote说明。理由:auto档下宿主 auto-review 审查每一次工具调用,而派出去的会话无人值守——审查器没按约定给出判决时,宿主直接按拒绝处理、不重试也不降级,会话就卡在原地。要auto就显式传。 - 没有
commands.execute时设不了档:保持不传,吃宿主默认(本机默认就是全权限),并在结果里说明。 - 显式要
auto时:宿主名单明确没有就整单失败、不建会话(host-missing);名单读不到就照旧下发,让宿主的/permission自己去判认不认这个名字。 auto档的第二步是把审批策略写成never:auto自带ask,无人值守时 reviewer 判 deny 会变成整晚卡死。这一步走session.append('approval/policy', { policy: 'never' }),不调ApprovalService.setPolicy(后者会往会话里注入一条假消息);写不进去就归档这个会话并整单失败——让会话带着ask活下去,等于埋一颗整晚卡死的雷。- 有一处宿主机制产物要知道:
/permission auto那一步自己会写一条ask,并往会话里注入一句「changed from "never" to "ask"」的 user 消息;本插件紧跟其后写never,最终生效的是never,但读 transcript 时会看到那条消息。
派送台账
每次 create_session 成功,都会往派送台账追加一行:
$DSH_HOME/bot/session-create/dispatched-sessions.jsonl # 可用 DSH_SESSION_CREATE_LEDGER 覆盖
每行记住:会话 id、标题、落位、提示词开头、派送时刻,以及这一单实际下发的 preset / permission / permissionOverriddenFrom / approvalPolicy,和派活人的会话 id(回执按它投回去)。建了会话、但权限档这一步失败的单会补写一行 failed(src/core.ts:679),否则事后谁都在会话列表里找不到那个空会话;提示词投递失败走另一条路——归档会话并报 prompt-failed,不写台账。
台账写失败不会让整次派送算失败(会话已经建出来了),但结果里会带 ledgerError,并写明「自己手动记下这个 session id」。
落点为什么在 bot/ 下:这份账是 Bot ↔ 人的交接记录,属于 DS Bot 自己的数据,所以跟在 DS Bot 的 home(<DSH_HOME>/bot)里。若 ds-bot 的 home 被它自己的 profile 配置改到别处,用 DSH_SESSION_CREATE_LEDGER 对齐。
session_message
给一个已经存在、且还在跑的会话插一条消息,不新建会话:
| 参数 | 必填 | 说明 |
|---|---|---|
sessionId | 是 | 目标会话的 id(create_session 或 list_dispatched_sessions 给的) |
message | 是 | 插进去的那段话 |
- 走
controller.prompt(mode: 'steer')(宿主agent.steer):插进正在跑的那一轮,会话下一步就读到,不等这一轮结束。 - 目标会话的 turn 已经结束(宿主直报
idle)时拒投(session-idle),并指向create_session。判据只用宿主直给的状态,不替调用方判断「这条消息算新任务还是纠偏」——那是语义判断。这条是 2026-11-01 那单事故的防线:同一条会话被补了 16 轮裁决,历史涨到约 76 万 token,第二天宿主把它切到 512,000 窗口的模型后立刻放不下。 - 宿主没报状态时按「还在跑」处理,结果里如实标
targetState: unreported。 - 只说「已插进正在跑的那一轮」,不说「它看过了」——后者只有读回才能证明。
- 单次投递超时 10 秒(
MESSAGE_TIMEOUT_MS)。
read_session_transcript
按 id 取回一个会话说了什么、现在什么状态(最新若干条消息 + 当前这一轮结束没有)。不给 sessionId 就取台账里最近一条派送,所以「刚派完那单跑完没」不需要手动找 id。
last 给取回多少条(1..200,默认 40),更早的用更大的值再读。历史读回超时 20 秒(HISTORY_TIMEOUT_MS)。
list_dispatched_sessions
倒序列出台账里的派送,每条带此刻的状态:还在跑 / 上一轮已结束 / 还没起跑(not started)/ 读失败。用来挑「哪些可以收了」。
状态是怎么来的
会话「跑完没有」有两条来源,插件优先问宿主,拿不到才从会话自己的事件推:
- 宿主直给(
agent.status是running/idle)→ 输出写the harness reports this Session as running; - 事件推断(只有
turn/start没有turn/end)→ 输出写read from the Session's own events。
两种来源的措辞故意不同:推断不许冒充宿主结论。
读回走宿主自带的 ctx.sessionController.page({ address, throughSeq, maxMessages }, signal),参数形状逐字照抄 DS Bot 自己的三处生产调用(src/host/parts.js:1287、chat-index.js:58、usage.js:351)——不自己造一套读事件的机制。宿主内置的 session_event_read / session_search 工具对普通会话不可用(返回 No session matched "…"),故不走。
派完自动回执
派送时把派活人的会话 id 记进台账。目标会话正常收尾(turn/end.reason.kind === 'completed')、安静满 120 秒(QUIET_WINDOW_MS)、宿主复核为 idle,并且它自己派出去的活都已经停下来,插件才用 controller.prompt(mode: 'queue') 投一条短回执回派活人(src/core.ts 的 createDispatchNotifier,src/index.ts:299 的 installDispatchNotify 挂到 ctx 上——没人调用工具时也要有东西替我们盯着)。
投递面是收窄过的:把假回执(关停、重启掐断、报错、卡住)和分段派工(还在等自己派出去的活)这两大类挡掉。但要说清楚——turn/end 这一层看不出「这一单」的边界,收窄不等于只剩整单完成。2026-11-03 拿本机派单台账 126 单逐条回溯(含静默窗口与三道门):旧口径会投 160 条回执,新口径 154 条(少 3.75%,其中真正被门三拦下的是 0 条);120 单仍会收到至少一条,11 单会收到多条,最多的一单 11 条。想再降只能改协议(例如一次派单只投一条回执),不在这一版里。
- 只认正常收尾:
aborted(关停/叫停)、interrupted(重启掐断)、error、blocked、max-tokens,以及认不出来的取值,一律不进静默窗口、一个回执都不发(COMPLETED_REASON,门一)。取值来自 2026-11-03 全扫本机<DSH_HOME>/data/sessions/**(当日 1,740 份文件、7,851 条turn/end)的统计:completed6,914 /aborted464 /error171 /blocked153 /interrupted121 /max-tokens29。 - 还在等自己派的活就不算收口:台账里
dispatcherSessionId指向它、宿主又报着没停的子会话,说明它是在等它们(firstBusyChild,门三)。等的时候不投;子会话停下、它被叫醒再跑一轮、这一轮没有在跑的子会话,才投。 - 机械区分不了的那一类:自己没派人、也没出错,就是这一轮干完了停下——这与「整单完成」在宿主事件面上同形,插件不替派活方猜,照旧投一条;由派活方自己按纪律决定要不要理它。
回执走 queue 不走 steer:它是给派活人的旁路通报,排到下一轮,不去打断他手头正在跑的那一轮。
宿主启动自检(重启后自动接续)
宿主重启时在跑的 Bot 会话一并被杀,醒来后没有东西会主动说「刚重启过、盘上有接手指针」。这一段补上它(src/autoresume.ts,src/index.ts:303 的 installAutoResume):
- 只认两个文件:指针
restart-pending.json,已确认记录restart-pending.ack.json。目录运行时算,不写死:DSH_AUTORESUME_STATE_DIR><DSH_HOME>/bot/session-create(DSH_HOME缺省~/.dsh,与派送台账同一目录;src/autoresume.ts的resolveAutoresumeStateDir)。要接到别处就设那个 env,不用改代码。 - 目标只能是指针里的
notify_session_id:缺失或不是字符串就跳过。不猜主会话、不遍历会话列表。 - 投三次:间隔 6s / 12s / 20s(
src/autoresume.ts:50,AUTORESUME_RETRY_DELAYS_MS;38s 是三次间隔之和,不是总超时——单次投递上限 10s)。宿主刚起来那几秒恰恰是最可能投不进去的时刻,所以单跳不够;只有send-failed才排下一次,送达、已确认、没指针、没目标都是终态。 - 幂等只看
written_at:已确认记录里的written_at不严格新于指针的written_at就跳过。失败也写一份 ack,但故意不写written_at——它只是盘上的证据,不参与幂等判断,所以下次启动照常重试。 - 只投
prompt(mode: 'queue'),不建会话、不改会话状态。唤醒正文只给指针的绝对路径和固定一句「上一个宿主 pid」,让收到消息的一方自己去读盘,不抄正文。 - 关掉它:
DSH_AUTORESUME_DISABLE=1(在读任何文件之前就判断,连定时器都不注册)。 - 全程 fail-open:读不到盘、JSON 坏、没有目标,都只记一行日志就安静退出;定时器
unref,不阻塞宿主启动也不拖住宿主退出。
失败时的行为(fail-closed)
位置不成立就什么都不建,直接报错:
| 情况 | 结果 |
|---|---|
| 文件夹不存在 | 报错,不建会话 |
| 工作区认不出 | 报错,不回落到默认工作区 |
workspace/folder 两个都给或都不给 | 报错 |
preset 名字宿主不认 | 报错,不建会话 |
model 不是 provider/model | 报错(model-invalid),不建会话 |
| 派活人的模型读不到 | 报错(dispatcher-model-unknown),不建会话 |
显式要 auto、宿主名单里没有 | 报错(host-missing),不建会话 |
| 宿主缺所需服务 | 报错,附上缺了哪几个 |
| 权限档设不上 | 归档这个会话,整单报错 |
auto 的审批策略写不进 | 归档这个会话,整单报错 |
| 提示词投递失败 | 归档这个会话,如实报错,不对外说成功 |
| 补话给已经停下的会话 | 拒投(session-idle),什么都不发 |
那张「宿主缺服务」的报错要能真的报出来,靠的是服务不写进 inject 硬声明:插件只 inject: ['tools'],其余服务都走 ctx.get() 宽容路径(src/core.ts 的 resolveServices)。写成硬 inject 的话,宿主缺服务时整棵插件树会加载失败,preflight 那句可读报错根本没有机会执行——用户只会看到「工具不存在」。
这里和 DS Bot 的 roster.js 有一处有意不同:它会「工作目录没了就悄悄换到团队工作区」,本插件不这么做——悄然换位置会让人以为任务落在 A 项目,实际跑在别处。
归档失败时,报错里会改成说「列表里可能还留着一条空会话」——不谎称已归档。
装
推荐以链路方式装进 profile:
# 在 profile 目录里(<DSH_HOME>/profiles/<profile>)
# 把 "dsbot-session-create": "link:<本插件目录的绝对路径>" 加进 package.json 的 dependencies
或直接把本目录复制进 node_modules/。装完 必须重启 DSH(重启只能由用户执行)。
cordis.patch.yml 负责把它挂上:
- insert:
- id: session-create
name: dsbot-session-create
开发
bun run build # src/*.ts → index.js(@deepseek-ai/dsh-tools 保持 external)
bun test # 165 个测试、5 个文件;plugin.test.ts 测的是构建产物,跑前先 build
类型检查(本仓不带 typescript 与 @types/node 依赖,用现成的那份就行):
node <某个 node_modules>/typescript/bin/tsc --noEmit -p . --typeRoots <同一个 node_modules>/@types
--typeRoots 显式指过去,是因为 tsconfig.json 里没有写任何本机路径:它只声明 "types": ["node"],落在哪由你调用时决定。
几个必须知道的事实
@deepseek-ai/dsh-tools不在 profile 的node_modules里(在宿主app.asar内,加载插件时注入)。所以test/stubs/dsh-tools.ts是它的替身,tsconfig.json的paths只为本机开发把它指过去——那不是权威类型定义,是按 DS Bot 的实际用法反推的最小形状。- 宿主 API 的形状(
create返回{ sessionId }、prompt要requestId+mode+content:[{type:'text',text}])是从 DS Bot 的源码读出来的,不是从官方文档抄的。上游没有这层文档。 - 代码与文档里不许出现本机路径(2026-11-02 用户定):源码、测试、注释、配置一律不许写盘符路径、用户名或本机目录名。唯一例外是单元测试里验证「Windows 路径怎么处理」用的虚构样本(
test/read.test.ts、test/core.test.ts与test/autoresume.test.ts里那几个),它们跟这台机器的实际目录无关。默认落点由DSH_HOME/homedir()在运行时算,要换就加一个 env 覆盖;test/autoresume.test.ts有一条用例扫src/下全部.ts(不递归子目录),命中盘符形式的路径就判红。 - 启动自检的落点跟随上面的规则:换机器、换
DSH_HOME都不用改代码;要把指针接到某个固定目录,设DSH_AUTORESUME_STATE_DIR。 - 派出去的提示词走
queue,补话走steer,回执走queue——三个通道口径不同,改的时候别顺手统一。
已验证 / 尚未验证
已验证(2026-11-03 实测)
bun run build之后git status只见预期改动:index.js与src/同步(64,495 字节,sha256040124bb…989)。bun test:176 pass / 0 fail / 654 次断言,5 个文件(bun 1.3.13)。tsc --noEmit -p . --typeRoots <现成的 node_modules>/@types(借一份现成的typescript与@types/node,见「开发」):0 个错误。不带--typeRoots会报error TS2688: Cannot find type definition file for 'node'.并退出码 2——本仓没有自己的node_modules。- 0.2.0 那份构建的描述文本确实在真实宿主里加载过:宿主会话的工具表里
create_session带上了model参数与auto权限段(never the host's global default、DSBOT_ALLOW_RESTRICTIVE_PERMISSION),说明 profile 挂的就是本仓的index.js。0.4.0 与 0.5.0 的改动都要重启宿主之后才生效(重启只能由用户执行)。
尚未验证
ctx.sessionController.page在真宿主里是否真按{ records, hasMore }返回(参数形状抄的是 DS Bot 生产代码,不是官方文档)。steer插队以后,目标会话是否真在下一步就读到(只验到「宿主受理」这一层)。auto档写approval/policy: never在真宿主里是否被认(单测覆盖的是「写不进去就归档并报错」这条路)。- 启动自检的真重启投递:注册、延迟序列、幂等判据都有单测,但没在真重启里收过那条唤醒消息。
- 提示词的 30 秒超时上限没有「真触发」用例——bun 1.3.13 的
AbortSignal.timeout在进程没有其它待办定时器时不触发(宿主是 Node,不受影响)。 last夹到 1..200、limit夹到 1..20 是本插件自定的范围,宿主有没有更低的天花板未验。
许可
MIT