# dsh-nightshift(夜航)设计规范 > DeepSeek Harness 第三方插件:白天高峰时段把任务排队冻结,低谷电价时段自动逐个执行、失败自动重试,跑完生成省钱报告。 > > 状态:v1.1(集成对齐完成)。本文件是 host 与 client 两个实现线的共同契约。实现中发现 SPEC 与 dsh 源码冲突时,**以源码为准并回报 team-lead 修订 SPEC**,不得私自变更接口形状。SPEC 中标注 `[TBD-验证]` 的条目由对应实现者从源码确认后回填——**全部已回填销账**(#5 slot props 形状、#6 dsh.client.inject 裁定)。 ## 0. 产品定义 **一句话**:白天排队,错峰自动跑,跑完看账单。 | 能力 | 说明 | |---|---| | 入队 | 会话输入框旁「夜航」按钮:把当前输入框内容作为任务入队,续当前会话;「新会话」按钮则以 fresh 模式入队(cwd = 当前会话所属工作区路径,未分组时缺省) | | 峰时冻结 | 高峰时段(默认北京时间 09:00–12:00、14:00–18:00)不派发任何任务 | | 错峰 drain | 非高峰时段逐个派发(含午间、夜间、凌晨),一次只跑一个,冷会话自动 resume | | 失败处理 | `turn/end` 结构化原因分类:error→退避重试;max-tokens→自动「继续」;blocked→标记等人工;aborted→视为用户取消 | | 省钱账本 | 实测 token 用量 × 峰谷单价记账,报告展示绝对省钱额与 token 数 | | 报告 | 队列清空(或高峰来临时)生成报告,面板一键查看 | **非目标**(v1 不做):跨机器同步、并发执行、working tree 冲突检测、自动 git 分支、余额/钱包查询、多语言 UI(中文为主)、报告注入会话(v1.1 候选)、watchdog 自动纠偏(v2 候选)、模型可调用工具 `nightshift_enqueue`(v1.1 候选,理由:工具 schema 会进入所有会话的 system prompt,与"省 token"的立身之本冲突;默认不注册,config 预留 `exposeTool: false`)。 ## 1. 仓库布局 ``` C:\code\learn\dshPlugin\ ├── package.json # dsh-nightshift bundle 清单(dsh.bundle + dsh.client) ├── cordis.patch.yml # - insert: [{ id: nightshift, name: dsh-nightshift }] ├── index.js # host 半(ESM cordis 函数式插件) ├── client.js # client 半(手写 __ModuleLoader__.load 工厂) ├── lib/ # 纯逻辑(零依赖,host 半 import,独立可测) │ ├── schedule.js # 峰谷窗口判定纯函数 │ ├── queue.js # 队列状态机 │ ├── retry.js # 退避策略 │ ├── ledger.js # token/费用账本 + 省钱计算 │ ├── report.js # 报告汇总 │ └── format.js # 展示格式化(client 也复用的纯函数) ├── test/ # vitest 单测 ├── install.ps1 # 一键安装 ├── README.md / README.zh.md └── LICENSE # MIT ``` - 语言:**纯 ESM JavaScript + JSDoc**,无构建步骤。运行时直接依赖仅 dsh 提供的包(见 §11)。 - 测试:vitest(devDependency)。lib 纯函数直测;index.js 用 `vi.mock` 掉 dsh 包测调度行为。 ## 2. 关键技术事实(已由两份源码调研确认) ### host 侧 | 事实 | 依据 | |---|---| | `TokenUsage` 字段:`inputTokens`/`outputTokens`/`totalTokens?`/`cacheReadTokens?`/`cacheWriteTokens?`/`reasoningTokens?`(reasoning ⊆ output) | `packages/llm/llm/src/types.ts:135-149` | | 每回合用量 = `ctx.sessionProjections.stateOf(session,'tokenUsage').totals` 回合前后差值(四桶:uncachedInput/output/cacheRead/cacheWrite;token-meter 在 base bundle 默认挂载)。已验证:`stateOf` 第二参为注册 key(`'tokenUsage'`),键未注册时返回 `undefined`——实现须判空告警(fail-loud,不可静默记 0);totals 桶名字段为 `uncachedInputTokens/outputTokens/cacheReadTokens/cacheWriteTokens`(读取时映射到 ledger 四桶) | `packages/session/session-projection/src/index.ts:307-315`;`packages/llm/token-meter/src/usage-projection.ts:38-41,120-157`;`packages/bundle/base/cordis.patch.yml:323` | | harness **无任何定价/余额/峰谷感知** → 单价全由插件 config 提供 | 全库搜索确认 | | config:函数式插件导出 `Config`(Schema.object),`apply(ctx, config)` 第二参注入,改配置触发 HMR 重载 | `docs/user/develop/basic/config.md` | | 持久化:`await ctx.storageDomain.open(spec)`(zod 行 schema),读同步、写 `put/update` 落盘后发 `domain/changed`,**不可原地 mutate**;调用方自持生命周期(`ctx.effect(() => async () => domain.close())`) | `packages/feedback/message-feedback/src/spec.ts:84-90`、`index.ts:173-181` | | host 进程随 web profile **常驻且独立于浏览器**(refresh 不丢队列);headless 是一次性的 | `packages/bundle/headless/src/index.ts:205`;apps/cli 无 serve/daemon 子命令 | | 冷会话:`ctx.sessionController.resolveAgent(sessionId)` 对冷 session 自动从盘 resume,并发去重 | `packages/api/session-controller/src/agent.ts:165→178→393` | | 夜间派发**不走 prompt RPC**,直接 `agent.followup(msg)`(少一层,语义同 queue 模式) | `packages/api/session-controller/src/commands.ts:283-336` | | `UserMessage` 构造:`createUserMessage({content:[{type:'text',text}], source:{kind:'plugin',plugin:'nightshift'}})`,id 自动生成 | `packages/llm/llm/src/message.ts:194-201`;样板 `packages/schedule/schedule/src/runtime.ts:271-275` | | `turn/end` 事件:`ctx.on('session/event',(session,event)=>…)`,`data:{turn,reason}`,`reason.kind`∈completed/aborted/blocked/error/max-tokens/interrupted | `packages/core/session/src/types.ts:232` | | 进程级定时器:顶层 `apply` 里 `ctx.effect` 挂**自递归 setTimeout**(单次 ≤ 2³¹-1ms 钳制),禁 setInterval;**不得挂在 session/agent 级 effect** | 样板 `packages/schedule/schedule/src/{index,runtime}.ts` | | 会话创建:`sessionController.create({cwd?, sessionId?, agentPreset?})`;**不要用 subagent 机制**(`origin==='subagent'` 被 API 路由拒绝) | `packages/api/session-controller/src/types.ts:255`;`agent.ts:79-102` | ### client 侧 | 事实 | 依据 | |---|---| | 单包双半:`package.json` 同时声明 `dsh.bundle.patch` 与 `dsh.client{platform:'web'}` + `exports["./client"]`;`cordis.patch.yml` 一行 loader 插入同时带起两半(client 扫描器只扫已加载 loader 条目);`dsh.client.inject` 是可选的**到达顺序边**(非硬依赖,web boot 的 `loader.create` 不消费),仅当 require 非 seed 包时才需要——本插件不加 | `packages/client/modules/src/index.ts:577,760`;`web/src/boot.ts:129`;`modules/src/client/system.ts:165-168` | | client.js 产物形状:`window.__ModuleLoader__.load({id:"dsh-nightshift", factory:(require)=>{ var module={exports:{}}; var exports=module.exports; /*…*/ return module.exports }})`,classic script | `packages/client/tsdown.client.ts:566-568` | | 可 require 的键仅平台基座:`react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/cordis`、`@deepseek-ai/dsh-client-store`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-primitives` | `packages/client/web/src/platform.ts:8-14` | | 仓库外插件自行维护 client.js 是官方承认路径(clientBundle 预设不随包发布) | `packages/client/ui-settings-plugins/README.md:96` | | npm bundle 的 client 是**全权页面 JS**:无沙箱、无 guard 门面、React 18 与 shell 同实例、可自由同源 fetch(guard/reportRenderFailure 仅属 dynamic-cordis 路径) | `packages/extensions/cordis-client-runner/src/client/*` 对照 | | slot 注册:client 插件 `inject:['slots']`,`ctx.slots.register({name,id?,order?,…}, Component)`;**组件永远拿不到 ctx**,只收 props。乱序激活用 `ctx.slots.inject(key, () => ctx.slots.register(...))` 包裹(声明已存在则同步执行,塌缩自动回收) | `packages/client/ui-schedule/src/client/index.ts:23-35`;`ui-renderer/src/client/registry.ts:172-234`;`packages/client/AGENTS.md:38-40` | | 相关 slot:`conversation.input.dock`(输入框停靠,session 作用域)、`sidebar.footer.action`(侧栏底入口)、`conversation.session.header.actions`、`settings.section` | `docs/subsystems/slots.md:110-163` | | **第三方不可用 @Remote/Typert**(贡献由根构建固定装配);读路径用 **exact fetch route**:host `inject:['connection']` → `ctx.connection.fetch.register(route)`,路由必须在 `/api` 下且仅 GET/HEAD,自动获得 Origin/Host 围栏 + HttpHttp cookie 认证 | `packages/connection/src/rpc.ts:122-130`;`rpc-host.ts:285-296`;活例 `packages/session-query/session-log-export/src/index.ts` | | 写路径(POST):走**裸 webServer 路由 + handler 内自调 `connection.requestRejection` 鉴权**(webserver 本身不识 harness 概念)。已验证:`ctx.webServer.register({kind:'exact', path, handler(req,res)}) → disposer`(`packages/host/webserver/src/index.ts:42-48,165-172`;函数式插件活例 `packages/webhook/webhook-github/src/index.ts:47-62` 以 `ctx.effect(() => ctx.webServer.register(route), …)` 挂载);鉴权 `const rejection = ctx.connection.requestRejection(req); if (rejection !== undefined) { res.writeHead(rejection); res.end(); return }`(`rejection`∈`401|403|undefined`,`packages/client/connection/src/rpc.ts:85,174-179`;调用样板原在 `packages/api/gateway/src/index.ts:211-228`——注意该处是 registerUpgrade(WebSocket mux),HTTP POST 的 requestRejection 样板即 webhook-github 组合) | `packages/api/gateway/src/index.ts:215`;`packages/webhook/webhook-github/src/index.ts` | | 推送无第三方通道 → client 用 `useEffect+setInterval` 轮询(ui-schedule 同款) | `ui-schedule/src/client/ScheduleCatalogAction.tsx:125-130` | | 样式:只用 `--dsw-alias-*` 语义 token(禁字面颜色/组件内明暗分支);手写 client 自插 `