🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

常驻助理 dingchenhui0618-arch/dsh-den

DeepSeek Harness
dingchenhui0618-arch15 天前有提交已提供中文文档扫描:含敏感能力

全天候值守,替你读遍会话与工作频道并起草回复

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/16 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add dingchenhui0618-arch/dsh-den
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · skill
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 15 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 34 处,展示前 32 条)
  • 写入剪贴板
    src/client/client.js:570
    clipboard.writeText(token).then(
  • 发起网络请求
    src/client/client.js:1772
    return fetch(API, {
  • 读取本地文件
    src/atom.js:157
    names = readdirSync(directory)
  • 读取本地文件
    src/atom.js:166
    if (now - statSync(candidate).mtimeMs < STALE_TEMP_MS) continue
  • 删除本地文件
    src/atom.js:167
    unlinkSync(candidate)
  • 写入本地文件
    src/atom.js:185
    mkdirSync(directory, { recursive: true, mode: 0o700 })
  • 写入本地文件
    src/atom.js:189
    writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`)
  • 写入本地文件
    src/atom.js:194
    renameSync(temporary, path)
  • 写入本地文件
    src/atom.js:210
    mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  • 删除本地文件
    src/atom.js:221
    unlinkSync(prepared)
  • 读取本地文件
    src/atom.js:234
    if (!existsSync(path)) return undefined
  • 读取本地文件
    src/atom.js:235
    return JSON.parse(readFileSync(path, 'utf8'))
  • 读取本地文件
    src/atom.js:248
    if (!existsSync(path)) return { ok: false, reason: 'absent' }
  • 读取本地文件
    src/atom.js:251
    value = JSON.parse(readFileSync(path, 'utf8'))
  • 读取本地文件
    src/grant.js:289
    return readdirSync(dir).some(function (name) {
  • 删除本地文件
    src/notices.js:222
    unlinkSync(path)
  • 读取本地文件
    src/watcher.js:46
    buckets = readdirSync(root, { withFileTypes: true })
  • 读取本地文件
    src/watcher.js:54
    sessions = readdirSync(join(root, bucket.name), { withFileTypes: true })
  • 读取本地文件
    src/watcher.js:62
    const info = statSync(path)
  • 读取本地文件
    src/resident.js:147
    const value = JSON.parse(readFileSync(this.path, 'utf8'))
  • 读取本地文件
    src/resident.js:158
    return { pid: null, instance: null, renewedAt: statSync(this.path).mtimeMs, unreadable: true }
  • 删除本地文件
    src/resident.js:167
    rmSync(this.path, { force: true })
  • 写入本地文件
    src/resident.js:272
    mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
  • 写入本地文件
    src/resident.js:281
    writeFileSync(descriptor, JSON.stringify(this.#record(now)))
  • 删除本地文件
    src/triggers.js:265
    unlinkSync(triggerPath(id))
  • 读取本地文件
    src/freshness.js:89
    for (const entry of readdirSync(root, { recursive: true })) {
  • 读取本地文件
    src/freshness.js:93
    info = statSync(join(root, name))
  • 读取本地文件
    src/session-log.js:145
    if (statSync(candidate).isFile()) return candidate
  • 读取本地文件
    src/session-log.js:157
    entries = readdirSync(dir, { withFileTypes: true })
  • 读取本地文件
    src/session-log.js:167
    if (statSync(candidate).isFile()) return candidate
  • 读取本地文件
    src/session-log.js:189
    return Object.assign({ path }, transcriptFromEvents(decodeLog(readFileSync(path)), { limit }))
  • 读取本地文件
    src/tasks.js:235
    return String(JSON.parse(readFileSync(pointerPath, 'utf8'))?.sessionId ?? '').trim()
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

DeepSeek Harness 的 7x24 常驻个人助理:与人绑定的长期自主权、重启后自我唤醒、带过期感知的通知(不重要就保持沉默),以及对每个会话、这台机器和个人工作渠道的只读触达——它读一切、起草一切,却不写入任何东西。

README

由 DeepSeek 最新模型翻译生成
dsh-den 一个 7×24 常驻个人助理,为 DeepSeek Harness 而生。 不是又一个聊天窗口——它一直开着,有自己的记忆,你不在时照样干活,一旦要越过你的信任边界就停下来先问你。 状态:核心已实现、已测试,并在真实配置档中运行。 这一行故意不写测试数量:写死的数字反复烂掉(307、309、316、319、320、322、323、326、327——每次烂掉时测试套件其实都是绿的),而上一次烂掉就发生在「修复」之后一轮。所以这一行只说明什么是稳定的,具体数字交给 npm test 打印。插件已加载进真实的 DSH 0.2.0-rc.2 桌面配置档——它在该配置档的 dsh.profile.bundles 里,运行中的宿主把它报告为 fiberPhase: active——注册了 16 个面向模型的工具、审批守卫、心跳,以及一个交互式状态面板。验证对象是运行中的系统,而不只是测试:心跳按 60 秒节奏跳动,面板是 sidebar.panellist(id: dsh-den.status,order 245)和 main 面板(key: dsh-den.status)两处的活跃占用者——这些是从实时浏览器页面通过宿主的只读 Slot inspector 读到的,不是插件自己注册调用时说的。
展开完整文档(README 剩余 1 节)
dsh-den

一个 7×24 常驻个人助理,为 DeepSeek Harness 而生。
不是又一个聊天窗口——它一直开着,有自己的记忆,你不在时照样干活,一旦要越过你的信任边界就停下来先问你。

状态:核心已实现、已测试,并在真实配置档中运行。 这一行故意不写测试数量:写死的数字反复烂掉(307、309、316、319、320、322、323、326、327——每次烂掉时测试套件其实都是绿的),而上一次烂掉就发生在「修复」之后一轮。所以这一行只说明什么是稳定的,具体数字交给 npm test 打印。插件已加载进真实的 DSH 0.2.0-rc.2 桌面配置档——它在该配置档的 dsh.profile.bundles 里,运行中的宿主把它报告为 fiberPhase: active——注册了 16 个面向模型的工具、审批守卫、心跳,以及一个交互式状态面板。验证对象是运行中的系统,而不只是测试:心跳按 60 秒节奏跳动,面板是 sidebar.panellist(id: dsh-den.status,order 245)和 main 面板(key: dsh-den.status)两处的活跃占用者——这些是从实时浏览器页面通过宿主的只读 Slot inspector 读到的,不是插件自己注册调用时说的。

这些实时结论在 2026-10-14 重新确认过,对象是报告 startedAt: 2026-10-14T02:10:43.000Z 的那个实例,它自己的新鲜度判定是 known: true, stale: false,并且在最后一次源码编辑后 1000 秒加载了这棵树。npm run verify:live 对它退出码为 0,七个实时探针全部通过:心跳按 60 秒节奏跳动,路由有响应,智能体无法从实时宿主那里拿到常驻自主权,审批闸门是通往权限的唯一路径,过期规则在宿主自己的目录里生效,场景隔离对宿主读取的同一个存储成立,磁盘证据显示 den_ 工具确实被调用过。更早的那个实例 08:43:05.676Z 正是这一段存在的原因:面板修复在 09:24:00 落地之后,它仍在提供旧面板,而六个实时探针继续打印通过——每个探针各跑各的,每个描述的都是磁盘上已经不存在的代码。现在有两件事让那种状态无法再被报告成通过。插件会报告 status.freshness:拿它旁边最新的源码编辑时间对比自己的启动时间,按请求计算而不是启动时缓存,并且把「说不清」当作自己的答案,而不是默认健康。每个实时探针脚本都串在 tools/require-fresh-host.mjs 后面,实例过期或说不清时,它拒绝运行。
面板改动是否生效,由页面发出的请求来验证,而不是靠截图。 在 09:52:28——那次启动之后 141 秒——一个 panel-autonomy-requested 事件到达路由,带着真实的会话 id,session-95cc8a8d-…。之前那个面板没有会话可传(解析当前会话正是缺陷所在,它传的是 null),而宿主已确认在跑新代码,所以只可能是 dock 发出的;探针自己的请求可以通过字面会话 id 辨认(live-autonomy-probe、grant-bypass-probe)。两个独立事实——宿主加载了这棵树,以及一个只有 dock 能发出的请求到达了——比截图更可靠,而且这里仍然不声称有截图。

面板之前占了大部分屏幕,这是设计失误,不是布局问题。 消息框上方的 dock 一直渲染整个自检创建表单——标题、提示词、间隔、提交。它放在那里是有原因的:dock 是 shell 唯一会交给真实会话的位置,而 schedules/create 没有会话就会拒绝,所以表单必须放在有会话的地方。但能力需要会话,并不需要出现在屏幕上。现在 dock 只有一行状态——autonomy、它的两个控件,以及一行 New 展开项——空状态则指出更省事的路径:在对话里说「每 30 分钟检查一次 CI」,den 就会加上。这一点足够小是机器检查出来的,不是嘴上说的:tools/check-client-interaction.mjs 要求 dock 在展开项被按下之前渲染零个输入框、没有提交按钮(之前它渲染了三个输入框和一个提交按钮),然后检查表单打开后仍然发送同样的 payload。这套逻辑正是本项目对标的参考助手所依据的逻辑——状态一眼可见,一次只做一件事,人负责说话而不是填表——这是转述而非引用,因为这个插件自己的门禁在 approval: never 的配置档里让 web_fetch 保持 fail-closed,所以那些页面在这个会话里读不到。
被承认的唤醒发生了——就在自检到期后的第一次心跳。 2026-10-14,面板铸造了一个请求,那个 token 作为人类消息回来了,den_set_autonomy 为 session-95cc8a8d-… 记录了 {ok: true, autonomy: true, boundToMessage: "b7b68b9f-…"}。随后创建了一个自检,下限 300 秒;它在 02:34:26 到期,下一个 tick——02:34:43,晚一个节拍——花掉一个回合并开口说话:账本变成 turns 0 → 1、spoken 0 → 1、notices 1,触发器被消费。宿主记录了事件本身,{"type":"wake-spoke","triggerId":"76e58cc0c47ef5…"},npm run drill:after 返回 RESULT: PASS(exit 0),其背后的守卫刚刚确认实例加载了这棵树。三个细节让它不止是一次成功的运行。同一个调用过去会被自动拒绝:门禁审计仍列着修复前实例的历史 den_set_autonomy → unknown-tool → ask 条目,而这次成功的调用不在该列表中——它被归类为 den 自己的工具。这个归类就是 DESIGN.md §2.28–§2.30 描述的修复:此配置档中的审批策略不是它自己的开关,而是权限预设的副作用(danger-full-access 蕴含 approval: never,此时门禁的 ask 意思是永不,而不是稍后),所以有一段时间,每个非只读的 den 工具都不可达——记忆、自检和权限都一样。答案是 den 自己的工具归类为自己的,每个都写明边界,而不是削弱那张表之上的凭据、shell 和出站规则;那些规则在应该拒绝时会照样拒绝本会话自己的调用。计数器是故意分开读的:turns 在投递后计费,spoken 只在唤醒真正说了什么时才计,所以一次被承认、又被判定不值得开口的唤醒会显示 turns 1, spoken 0 并让 drill 失败;两者都动了,所以这是一次开口的唤醒。而且授权绑定到消息,不是绑定到点击:boundToMessage 指名携带 token 的那条人类消息。仍然只在离线验证的是:重启之后的唤醒。触发器、授权和账本都活在磁盘上,挺过了把宿主移到 02:10:43 的重启;test/cold-start.test.mjs 通过真实 token 授权、存下一个触发器、把时钟推过重启并观察唤醒触发——但联合事件(先重启,再唤醒)尚未被实时观测,其反面(触发器到期却没有权限,保持沉默)也没有。两者都需要人类动作,而 npm run drill:before / npm run drill:after 就是衡量任一者的方式。DESIGN.md §2.35 有事件链和这两个缺口;§2.34 是这次 drill 第一次真跑时在自身发现的缺陷。
唤醒发了话,却没人被告知——这是本面板的一位读者发现的缺陷。 页面报告 Pending notices 1 / 1 和 Today's self-initiated turns 1,而人这边什么都没收到,消息框上方的条带还在提供一个已用过的 token,并附上粘贴它的说明。两者的成因形状相同:一个界面在报告它无从更新的状态。pendingNotices 只认得「可投递」和「已过期」,所以一条已经展示过的通知没法不再处于待处理状态——计数永远到不了零——而唤醒自身的实现把文本排进队列(queueNotice),却没有任何面向人的界面把它渲染出来:计数说有事发生,却没有任何东西说是什么事。修复在两端。通知带上 deliveredAt,只有显式确认才会设置它——从一次渲染推断「已投递」会让计数朝反方向说谎——状态快照带上待处理的文本,dock 把它渲染在人本来就会看的地方,配一个 read it 控件,而铸造出的 token 只在它还是一份未使用的邀请时才显示。DESIGN.md §2.37 有测量结果和两端修复;两半都由测试钉住,缺任何一半测试都会失败。

而且它仅凭这个仓库就能复现。 对已发布的仓库做一次全新的 git clone——没有 node_modules,因为这个包声明完全没有依赖——整套测试跑绿:

node_modules present in clone: False
dependencies declared: (none)
ℹ tests 326 ℹ pass 326 ℹ fail 0
test exit: 0

常驻工作新增的四个文件在那个克隆里按名字找过——src/resident.js、test/resident.test.mjs、test/two-process.test.mjs、test/support/resident-child.mjs——因为这份证明存在的理由,正是一个源文件躺在磁盘上、被代码 import、却从没进到仓库里。.gitignore 里一条光秃秃的 lib/ 就干过这事,本地测试全过,推上去的仓库却是坏的。

这才是值得精确说出的主张:不是「在我机器上能跑」,而是一个有 Node 二进制、别的什么都没有的第三方能验证这件事。npm run check:source-tracked 每次运行都断言它:磁盘上每个源文件都被 git 跟踪,而藏起某个文件的那条忽略规则会被点名。

一份记录不是记账。 上面那个数字曾在三个 commit 里从 240 改到 241 再改到 252,而最后一次真正的克隆运行打印的是 237——一个公开主张,底下没有命令,这正是这个仓库在自己工作里不断发现的缺陷。现在这个数字来自推上去的那个 commit 上的一次运行,而保持它如此的办法是重跑证明,而不是更新计数。
唤醒投递路径曾经是坏的,现在修好了——这段历史值得一读,因为整个过程中测试套件一直是绿的。 插件过去用 subagents.sendMessage(agent, trigger.sessionId, …) 投递唤醒,其中 agent 是目标会话自己的驱动。宿主只有在发送方是目标的直接父级或直接可续子级时才允许发送——authorizeLineage 要求 parentSession === parent.id——所以这行调用等于让一个会话当自己的父级,生产环境里每一次被放行的唤醒都挂了,报错 delivery requires the exact live parent agent。覆盖这条路径的五个测试全部通过,因为那些假实现放行了任何投递:替身撒谎的地方是调用约定,不是服务本身。

现在投递走 Agent.followup,宿主对它的说明是「排入一条普通的下一轮提示词并唤醒驱动」——send(message, 'next-turn', true),插进收件箱并打开一个轮次边界。不涉及任何血缘关系,唤醒在会话里明明白白就是一条排队的轮次,并且会持久化成一条 user/message,回复回读时与助手的回答配对。subagents 已经不再被任何地方读取。

投递面现在是在运行中的宿主上验证的,不只是在测试框架里。 npm run check:wake-api 会向活的 Host 询问它实际提供什么,并报告:

PASS — the agents service exposes get() PASS — the agents service exposes resume() PASS — followup is absent from the service contract, as expected PASS — a live agent was found and identified id: session-95cc8a8d-a3e9-4e2a-a67d-12292d17ba24 PASS — the live agent carries a callable followup() this is the capability the delivery path needs, measured on the running Host RESULT: PASS — 8 checks, and the delivery surface is the one this Host actually offers.

这项检查之所以存在,是因为它防范的缺陷本质上是对 API 的假设,而它被放在假设可以被测量的地方:agents.get/agents.resume 声明返回 Agent,而 Agent 声明为 { readonly id }。followup 不出现在任何服务签名里——它是工厂构建出的活智能体对象上的方法——所以暴露出来的契约比运行时对象窄,读再多签名也回答不了「投递在这台 Host 上能不能跑」。没有活智能体时,探针报告 sawLiveAgent: false,而不是通过:我没能看不等于它不在。

还没做完的是一步人工操作,不是缺陷。 至今没有任何被放行的唤醒真正投递过,因为放行一次需要常驻自主权,而还没有人授予它。那一次演练是整个项目里最后一条未验证的路径;它两侧的一切都已测量。代码证据见 DESIGN.md §2.7,假实现是如何被改得不再掩盖问题的,见 §2.8。
能力 4 已端到端验证通过,而验证方式本身才是重点。 门禁的秘密规则按路径匹配,并且以 ctx.tools.guard 的形式安装,因此它会拦截配置档里的每一次工具调用——包括助手自己的调用。这让零风险实机测试成为可能:读取一个凭据形状的路径,而该路径并不存在。门禁生效,调用在触碰文件系统之前就被拒绝;门禁失效,结果就是 ENOENT,什么都不会泄露。

Error: refused: this would pull a stored credential into the conversation (credentials-yaml)

这句话逐字出自 src/gate.js,宿主自己的服务事件流也把这次判定一并记录了下来:

{"type":"gate","tool":"read","rule":"secret:credentials-yaml","risk":2,"decision":"deny",
"sessionId":"session-95cc8a8d-a3e9-4e2a-a67d-12292d17ba24"}

那条流里还有两条记录,纠正了我自己的一次误判:outbound:curl-upload 和 outbound:recursive-delete,两者都是 ask。我曾向用户报告说这两次工具调用是「用户拒绝了」,实际上它们是被本插件自己的门禁拒绝的——分类为 ask,而宿主没有审批机制,于是把 ask 变成了拒绝。门禁一直在塑造这个会话。

后来,在 2026-10-14,第一条规则里的回环那一半被放行了——而且只放行那一半。 被问到边界时,人类回答「只放行本机」,于是 decide() 现在对目的地是具名回环主机(127.0.0.1、localhost、[::1],带端口或不带端口)的 outbound: 规则返回 allow。三条边界让这项许可和授予它的那句话一样窄:规则必须是 outbound: 规则;目的地必须真的写出来——缺失或无法解析的目的地仍然走 ask,正是这一点拦住了那些仅仅提到上传的文本被当作许可放行;例外分支位于拒绝分支之后,所以被允许的目的地永远救不回一个被拒绝的动作。让规则在两半上都触发的那个论点(回环服务可以中转)并未被推翻;它现在被记录为人类的决定,而不是插件的决定。test/gate-loopback.test.mjs 用门禁自己的阈值做校准,而不是照抄这些阈值,从而把每一条边界都钉死。
同一批证据还暴露出一个缺陷,值得点名,因为它是一整类问题,而不是一时疏忽:onDecision 通过 noteEvent(面板展示的内存环形缓冲)公布决策,从未调用 recordAudit,而 gateAudit() 读的是 gate-audit.json —— 一个没有任何代码写入的文件。于是面板的 gate 界面永久显示 「no decisions」,哪怕刚刚真实做出过三次决策。recordAudit 是死代码,持久化审计的设计一次都没跑起来。读一个从未被写入的文件,看起来和一道从未触发过的 gate 一模一样。 这个接缝现在已经修好,并从两端钉住 —— 既钉服务端的读取方,也钉磁盘上的文件 —— 因为只通过 gateAudit() 断言的话,如果读取方和写入方是同一块内存,测试照样会通过。

而且这个修复现在能在实时配置档里观察到,来自比写入它的那个进程更晚的进程。 重启之后文件存在,里面装的正是本次会话触发的那些拒绝:

{"at":1791868057296,"tool":"den_set_autonomy","rule":"unknown-tool","risk":1,"decision":"ask","sessionId":"session-95cc8a8d-…"} {"at":1791868277929,"tool":"plugin_manager","rule":"unknown-tool","risk":1,"decision":"ask","sessionId":"session-95cc8a8d-…"}

关于这个文件有三件事成立,而这三件事此前都只是设计意图:读取方有了写入方;记录比产生它的进程活得更久 —— 所以 「它为什么拒绝?」* 事后也能回答,而不是只在环形缓冲恰好还留着它的时候才能回答;条目带有工具、规则、风险、决策和会话,但不带参数,所以审计不会变成 gate 所保护内容的第二份副本。plugin_manager 也在里面,和 den 工具一样:gate 拦截配置档里的每一次工具调用,不只是安装它的那个插件自己的调用。

而且状态确实跨过了重启,这是在运行中的主机上测出来的,不是论证出来的。 插件自己的状态载荷带有这个实例上次加载插件的时间;状态文件带有它首次写入的时间。在实时主机上,这两者相差大约一小时,方向恰好证明了这个结论:

state.createdAt : 2026-10-13T01:49:11.717Z ← written by an earlier boot
plugin startedAt : 2026-10-13T02:56:33.794Z ← this instance (pid 36704)

所以上一个生命周期写了那个文件,这一个把它读了回来:助手自己重新起来了,配置完好,没有人参与。这就是能力 2 的持久化那一半,也是不需要任何人许可的那一半。
要走到这一步,得先修掉那个本该说明这件事的字段。stateSurvivesRestart 是在 status() 内部算出来的,算法是 readJson(statePath) !== undefined——而这时启动早就把那个文件写好了,所以在一台从未重启过的机器上、第一次启动时它就是 true,可它上面的注释却声称它 "cannot overstate what survived a restart"。它甚至已经被当成证据引用过:tools/check-acceptance.mjs 在一个名为 "boot persists state" 的检查里,断言首次启动进空 home 时为 true。现在改成在启动接管目录树之前采样,那时答案还可能是否,并且一并输出 stateCreatedAt,让读者能自己确认这个说法,而不是只能信它。验收块本来就会启动两次,所以这一修让该检查严格变强了:它现在观察到的是真实的 false → true 转变。这条规则可复用,同类问题已出现三次:字段名指向过去发生的事件——survived、recovered、delivered、refused——就必须在事件还可能没发生的时刻采样。采样晚了,它就从测量退化成常量,悄无声息,还面不改色地报 false。

live 配置档现在用一个 payload 就能看出两个版本的差别。 在 05:06:55.290Z 启动的那个实例上,stateCreatedAt 是 1791856151717——大约早 3.3 小时。所以 stateSurvivesRestart: true 是拿这个实例自己的启动时间做比较,而不是一个碰巧正确的常量;而那个过去缺失的字段,正是让这个说法可被检验的字段。也正是靠这个缺失,才在宿主仍在提供服务时识别出上一个构建是旧的。

正在运行的宿主服务的是旧构建;它后来重启了,当初指出这一点的同一个检查现在给出了相反结论。 重启前,Tool inspect 服务商报告的工具数量和这棵树的源码不一致,GET /dsh-den 返回 404。重启后,npm run verify:live 报告:

- GET /dsh-den → 200,信封格式良好
- state.json 由本次启动写入,包含 cooldownSeconds、createdAt、dailyTurnBudget、displayName、quietHours、tickSeconds、updatedAt、version
- 通过 Tool inspect 服务商看到 11 个工具,名称与这棵树注册的逐字节一致
- transport.browserReachable 已设置——真实浏览器访问到了面板路由,这是此前从未观察到的那道缝实时状态载荷随后报告智能体确实在运行:kernel.running: true、kernel.lastError: null、lastTickAt 是新的、gate.mounted: true,磁盘上的账本文件里 turns/spoken/skipped/refused 全为零。在同一个真实 socket 上跑了一次脚本化演练,端到端确认了写入路径——autonomy/request 签发了 32 个字符的 token,schedules/create → schedules/list → schedules/delete 走完一轮,10 秒的间隔被正确拒绝,报 everySeconds must be at least 300。演练创建的东西全部删除;~/.dsh/den/triggers/ 又空了。

之后一次重启让这一轮的修复生效,连续三轮失败的检查转绿了。 自 lease 相关工作落地以来,部署检查一直报 the running instance loaded this tree ... the host is running code older than this tree;主机在 2026-10-13T08:43:05.676Z 重新加载,晚于最新的源码修改(triggers.js,08:39:32.676Z),现在通过了。载荷里显示出上一版构建看不到的东西:

- residency 存在——{held: true, instance: "7969f300…", pid: 34336, staleMs: 180000, holderSince: …}。在这次重启之前的构建上,这个键是缺失的,所以这是 lease 代码真的在跑,而不是关于它的说法。
- reapedNotices: 0——随 notice sweep 一起加的字段,存在且能应答。
- 通过 Tool inspect 服务商看到 11 个 den_ 工具,与源码一一对应。部署检查点名说这是它从自己所在位置看不到的东西,所以直接查询了。
- 浏览器访问到了路由,时间 08:52:58.246Z,早于验证运行——是真实页面在轮询面板。

npm run verify:live → exit 0,全部十三个 check: 脚本 → 对运行中的主机 exit 0,包括六个 live 脚本:deployment、autonomy、notices、memory、tools、wake-api(8 项检查),外加 grant-bypass、bundle、ui、acceptance、package、install、source-tracked。

智能体自己开口说话还没发生,但原因是测出来的,不是猜的——而且边界原来是结构性的。 autonomy 是 null,autonomyKnown: false,因为还没有人授予它。

拒绝是刻意的,可以追溯到三个文件里各一行:

- src/gate.js 把所有持久效果留在默认层级,并在注释里说明原因:"den_set_autonomy is a privilege change." den_status、den_recall 和其他只读工具是 allow;这个不是。
- 运行中的主机上 den_explain_gate 报告 verdict: "ask",规则 unknown-tool。
- src/gate.js 还说明了主机不支持审批的后果:"a missing approval facility turns ask into a denial, which is the correct fail-closed direction." 本会话已禁用审批提示,所以 ask 会解析为拒绝。因此这条路径在智能体一侧是结构性关闭的,而不是操作员失误,并且从智能体回合调用 den_set_autonomy 在这里根本不可能成功。面板也不是替代方案:它的授权控件生成的是一个请求,而且它自己的文案里也这么写了——把这段 token 粘贴给对话中的助手——而 autonomy/confirm 会先解析一个活跃会话,然后才看其他任何东西。

这不再是来自规则表的论证——它已经在真实主机上、从对话本身尝试过了。 助手在运行中的系统上以 autonomy: true 调用了 den_set_autonomy,插件自己的门禁拦下了它,并写下了它做了什么:

{"type":"gate","tool":"den_set_autonomy","rule":"unknown-tool","risk":1,"decision":"ask",
"sessionId":"session-95cc8a8d-a3e9-4e2a-a67d-12292d17ba24"}

那个 sessionId 就是这次尝试所在的会话——不是代替它的探针。拒绝之后,同一份状态载荷报告 autonomy: null 和 grants: 0:权限变更没有发生,智能体也无法让它发生。 早前的几轮只能引用 den_explain_gate,它报告的是门禁将会做什么;而这次是门禁对一次真实调用实际做了什么。

它还解决了一个文档此前含糊的问题:den_ 工具可以被助手自己的回合调用——den_status 从对话里就能工作——所以这不是来自信任边界之外的假设性尝试,而是助手用它实际拥有的全部能力去够自己的权限。

给运行中的实例授予权限,一步一步来

能力 1 的反面已经在上文演示过了。正面则需要一个人,这一点绕不过去:智能体可以生成 token,可以引用任意消息 id,可以写入整棵树,但它无法编写 user/message,而授权正是绑定到它上面的。要复现:

1. 把会话的审批策略设为 ask。它是按智能体生效的——approval.setPolicy(agent, 'ask'),对应本会话启动时的 'never'(见下方契约)——Host 会在模型自己的运行时上下文中报告这一变更,所以助手能看出开关切换了,而不是靠推断。策略为 never 时,ask 解析为拒绝,den_set_autonomy 会被自动拒绝,这是正确的失败关闭行为,也是禁用审批的会话永远无法完成此流程的原因。
2. 打开 den 面板,使用它的授权控件。它会生成一个一次性、10 分钟有效的 token,并显示说明:把这段 token 粘贴给对话中的助手。
3. 把那段 token 粘贴到一条消息里。token 正是这一步的关键——智能体自己能生成的任何东西都证明不了什么。
4. 在同一轮里,助手调用 den_set_autonomy,传入 autonomy: true。它不需要 message id:den 会在当前轮里找到那条引用了 token 的人类消息,这条查找路径比 id 路径更严格,因为它要求 token。

5. 批准这次工具调用,而不是让它被拒绝。授权随即写入,并绑定到那条消息。

在事件日志里区分真人和探测。 面板授权会带上 conversation id,因为面板把请求限定在当前活跃会话内;路由探测带的是合成 id,如果它懒得带就干脆没有。这个区别必须写明,不能留给读者自己推断:check-live-grant-bypass.mjs 以前铸造时不带 session,留下 {"type":"panel-autonomy-requested","sessionId":null} —— 真人点 Grant 也可能留下同样的条目。现在它用自己的合成 id 给请求签名,并断言自己确实这么做了,因为一个探测留下的痕迹如果真人也能留下,日志就会在「谁动了手」这件事上说谎。

所以授权需要人类两次介入:token 必须被真实的人类消息引用,由此产生的工具调用必须被批准,而不是自动拒绝。这就是能力 1 最强的形态 —— 即使智能体对整个目录树有写权限,也无法给自己造出常驻授权。

拒绝在运行中的主机上是什么样子。 创建一个 once 触发器,到期时间就是当下,任何地方都没有授权。真实内核在下一次心跳时捡起它,并在自己的事件日志里记下:

{"type":"wake-refused",
"triggerId":"bc83e334…", "gate":"authority", "reason":"no standing autonomy from a direct human request", "hadSession":false}

同时 ledger.refused 递增,触发器的 fireCount 保持为 0 —— 意图存活下来,没有被消耗掉。这就是能力 1 和能力 2 合在一起,端到端跑在真实主机上:自主唤醒执行了,授权门以所述理由拦住了它。 演练结束后触发器被删除,~/.dsh/den/triggers/ 是空的。

这次演练暴露出两个生产缺陷,均已修复。 拒绝记录出现了两次,间隔正好六十秒,第一个缺陷就是这样暴露的:拒绝路径从不节流,于是一个永久未授权的触发器在每一次心跳时都被重新评估 —— 一个 once 触发器一天产生 1,440 条日志。第二个缺陷来自写一条断言,要求 tick() 只有一种返回形状:空闲分支漏了 results,忙碌分支漏了 woke/spoke,于是 tick().results.length 恰好在那些安静的 tick 上抛错,而安静才是常态。两个修复都在 src/kernel.js,回归测试在 test/cold-start.test.mjs,每个修复都通过回退验证过。

剩下未验证的一步是被放行的唤醒和长时间无人值守运行。两者都需要有人授予自主权。
授权流程在运行中的主机上做了 dry run,并且能正确拒绝。 下面每一步都走的是真实链路:

1. autonomy/request → ok: true,一个 32 字符的 token,以及一条指令,用一句话说明设计:调用 den_set_autonomy,autonomy=true,humanMessageId 填这条消息的 id,并引用 token …。它不创建任何授权。
2. autonomy/confirm 带上那个 token,但没有人类证据 → ok: false,DEN_ACTION_FAILED,"no live conversation for this token: autonomy is granted per conversation"。
3. 重复那次失败的 confirm → 同样的拒绝,所以这次失败没有消耗掉 token。

4. status afterwards → autonomy: null, autonomyKnown: false. Nothing was created.

Step 2's message is specific rather than generic because autonomy/confirm resolves the conversation itself and refuses before it looks at the message id — so a synthetic session stops one layer earlier than a real one would. That is why this dry run proves the transport and the refusal, and not* what a valid confirmation does. The valid case is the one awaiting a human.

That the plugin is live is proven from disk, not asserted. npm run check:tools reads the persisted session logs and pairs every tool/call record with its result — a call attempt is not evidence, because a model can name a tool that was never registered and the attempt is still logged; only a result that is not an unknown tool error proves the tool existed in that session's set. Verdict on the live profile: proven for den_remember, den_schedule_check and den_status.

What is observed, and what is not. For several rounds this section said the panel had never been verified in a real browser, and that was wrong — not the verification, the claim. The host exposes a read-only Inspect provider that queries the page the user actually has open, and it answers:

sidebar.panellist occupants -> { registrant: "dsh-den", id: "dsh-den.status", order: 245, active: true }
main occupants (keyed) -> { registrant: "dsh-den", key: "dsh-den.status", active: true }

That is the panel mounted in a real browser, on both slots this README claims, read from the live page rather than from the plugin's own registration call. The remaining unknown is narrower than "does it work": it is the rendered text and the click path — Slots reports topology, not pixels, and no browser automation is reachable from inside this profile (the GUI's shell requires the token dsh web printed, and the WebBridge route needs a body upload that this plugin's own outbound rule escalates). test/roundtrip.test.mjs still carries the request bytes, the response bytes, and the payload the panel receives, in one process.

Reachability is now attributable, not just stamped. transport.browserReachable is one timestamp, and a deployment probe and a panel that polls every fifteen seconds produce the same one. The route therefore records every request and keeps what the newest caller said about itself — transport.lastRequest carries the user-agent, origin, and referer verbatim, plus requestCount. Verbatim rather than classified, because a label would be the plugin's opinion about who called, and the point of keeping it is to let the reader decide. First contact still never moves: firstReachedAt is stamped once and stays.

Why this exists

The difference between an assistant and an agent is not model quality — it is duty cycle.

A normal agent is "you ask → it works → it stops." A resident assistant is "it is still there, it has its own memory and its own limits, it finds work when you are not looking, and it comes back to you before it sends, spends, or deletes anything."

Four things have to be true at once, or the thing is unsafe or unbearable:

| | Capability | Without it |
| 1 | Standing autonomy bound to a real human request | 7×24 means "an unattended script with your credentials" |
| 2 | Self-wake across restarts | "Always on" ends at the first process restart |
| 3 | A speak-only-when-it-matters gate, with expiring notices | It pings you every 30 minutes and you uninstall it |
| 4 | An outbound approval gate it cannot talk its way past | "It can send email and spend money" is not a sentence you can say |

Design rules this project inherited from real incidents

These are hard constraints, not aspirations. The reference implementations below each paid for one of them.

1. Autonomy is granted only by a direct human request, and the grant is bound to the host-attested session event that carried it. Timers, automatic continuation rounds, subagents, and scripts can never grant or revoke it.
2. A timer is not a permission. Every self-initiated run re-checks current authority, because the grant may already have been revoked. Revocation always beats automated re-arming.
3. A notice has a shelf life. Observation and reminder notices expire after 12 hours and are then deliberately not delivered. One recorded incident had calendar reminders queueing for 37–80 hours while their session was offline, then firing long after they mattered — a reminder delivered too late is worse than one never delivered. Ideas are exempt. And the other direction had to be measured too: because deduplication was decided by whether a file with that name existed, an expired record — which is still a file — silenced the same observation made again permanently, while queueNotice reported success. The guard is now "is an identical notice still deliverable", a dead record (expired or unreadable) is replaced rather than treated as a duplicate, and nothing had ever deleted a notice file, so an expired one is now reclaimed at boot and reported as reapedNotices.
4. Failure degrades, it does not throw. A missing, stale, foreign, or corrupt authorization record resolves to "not authorized". Throwing would turn one bad file into a denial of service; trusting it would turn one bad file into a privilege escalation.

Implemented so far

| Module | What it does |
| src/atom.js | Crash-safe JSON persistence: atomic publish, fsync barriers, content-addressed "publish once", validation-gated reads. Directory fsync degrades gracefully where the platform forbids it (Windows). |
| src/grant.js | Human-bound standing autonomy. Verifies the grant against the immutable session log; refuses to record authority for a caller that is not a live direct human driver. |
| src/notices.js | Durable notices with a 12-hour expiry for observations, no expiry for ideas, and content-hash deduplication. |
| src/triggers.js | Schedule definitions (once / interval / daily / weekly) with a 300-second interval floor, a bounded prompt, and a schedule-shape validator: a stored trigger missing the fields nextTriggerAt reads is reported invalid rather than listed as healthy and never firing. Downtime does not replay a backlog, but it does not preserve the grid either — the cadence re-anchors to the wake that actually ran (measured at 170 minutes of phase shift after a three-hour outage on a five-minute interval). A wake that was admitted but never reached the conversation is retried (15-minute throttle) instead of being charged and lost. |
| src/kernel.js | The heartbeat and the four gates: authority → budget → quiet → pacing. Re-entrancy guarded, every refusal recorded in a durable daily ledger, and the cadence advance committed only after a wake is delivered. |
| src/memory.js | Scenario-isolated long-term memory: one file per scenario, so personal context is structurally unable to surface while working in another domain. Deterministic keyword-plus-recency recall, no embeddings, corruption degrades to an empty store. Only the 200 newest entries are scored — ordered by createdAt, not by file position, because remember refreshes a re-learned fact in place; the window is not hidden, so den_recall returns stored / considered / scored and stored - considered is what it left out. |
| src/gate.js | The outbound approval gate: credentials refused, outbound actions questioned, unknown tools fail closed. Installed as both a pre-execute listener and a monotonic guard, and every tool this profile exposes is classified explicitly — an unlisted tool that falls to the fail-closed default becomes an automatic rejection wherever prompts are disabled. |
| src/panel-api.js | The panel's action surface: the status snapshot and the nine named actions, with no transport in them. Read-only actions and authority-changing ones are deliberately separated — see below. |
| src/panel-route.js | One prefix route on the host web server at /dsh-den, exposing that surface as JSON. The same shape every other client plugin in this profile uses. |
| src/client/client.js | The browser half: a hand-written lazy-CJS bundle with no build step, registering the sidebar entry and the panel, and reaching the host with the browser's own fetch('/dsh-den'). Grants nothing on its own. |
| src/freshness.js | Whether the running instance loaded the tree beside it: the newest source edit against startedAt, with "cannot tell" reported as its own answer rather than as fresh. |
| src/index.js | Plugin entry point, sixteen model-facing tools, and the wiring. Zero external imports. |

All state lives in a self-contained $DSH_HOME/den/ tree, so it can be packed and moved to another machine or a server without hunting through unrelated DSH storage.

npm test # unit + integration tests (the run prints the count)
npm run check:bundle # 31 offline bundle-contract checks
npm run check:ui # 54 panel interaction checks
npm run check:acceptance # the two reliability goals, as one observable lifecycle
npm run check:package # what a tarball install actually ships (static, offline)
npm run check:install # packs a real tarball, installs it, loads what was installed
npm run verify # all six, hermetic, no host required

these three need the running host instead of a fixture, so they are not in verify
npm run verify:live # after a restart: is the host serving this tree? (fails if it loaded older code)
npm run check:deployment # just the deployment half of the above
npm run check:tools # disk evidence that den_* tools were really invoked
npm run drill:before # the admitted-wake drill, frame one: after a restart, before granting
npm run drill:after # frame two: after the grant and once a self-check has come due
every live probe is chained behind require-fresh-host.mjs, which refuses to run it against an
instance that loaded older code — or that cannot say — so no pass can describe a tree the host never loaded. check-deployment is exempt: it is the measurement itself. The two drill frames are chained for the same reason: they read the running host's ledger and draw the last conclusion of this project from it.

check:package and check:install answer different questions, and both are needed. The first reimplements npm's files rules so it can run offline — it answers would these files ship? The second runs npm pack, extracts the tarball into a directory with no access to this repository, and brings the plugin up from there — it answers does what ships actually work? Since the objective is a plugin that is installed on a real host, installability is a claim worth testing rather than assuming. It deletes its temp directory in a finally, including on failure.

After restarting DSH, run npm run verify:live. It is the scripted form of the acceptance this project is judged on, and it refuses to report success while the host is still running an older build. It first runs the deployment check as a falsification step — if GET /dsh-den is not 200, nothing after it would be evidence about this tree, so it stops.

The one claim that cannot be scripted is scripted in two halves instead: npm run drill:before, then npm run drill:after. The admitted wake needs a human act — a message carrying a panel-minted token — so the drill cannot be a single command, but it can refuse to be a story. --before records the running host's own numbers (authority, heartbeat, residency, triggers, and the ledger's turns/spoken/skipped/refused) to ~/.dsh/den/drill-frame.json; --after re-reads them and draws a verdict that fails unless both counters moved, because turns is charged after delivery while spoken is charged only when the wake actually speaks. It also fails, by name, on the trap in the middle: a grant alone wakes nothing — authority is permission, not a schedule, so a drill with no self-check due can wait forever and would otherwise be reported as "no wake was delivered", which is true and useless. That verdict is a pure function with seven offline cases (test/drill-live-wake.test.mjs), tested before it is ever read from a live host.

The browser seam needed a timestamp, not a better intention. The script used to claim "a browser request has already reached the route" by reading transport.browserReachable — but step 1's deployment probe is itself a GET to that very route, so the field it read had been set by the script's own traffic, and it reported that as a browser having arrived. It now records when the run started and only counts a reach that predates it; anything newer is reported as unattributed. check-deployment.mjs, which cannot know when its caller started, no longer says "browser" at all — it says some HTTP request has reached the route, which is all the field can support.

"The running host is serving this tree" is a claim that has to be earned twice, and the second half was missing. Comparing the Host's tool names against the names in src/index.js catches a different build but not a changed behaviour — editing a handler does not rename a tool, so a host running last week's delivery path or last week's grant path still lists this week's tool names and passes. The check now also asks whether the running instance loaded after the last source edit, using the startedAt the plugin already reports as the moment the Host loaded these modules:

FAIL the running instance loaded this tree
the instance loaded at 2026-10-13T02:56:33.794Z but index.js changed at 2026-10-13T04:02:16.393Z
— the host is running code older than this tree, so restart DSH before believing anything below

That is the honest verdict while an edit is pending, and it means verify:live now stops instead of proceeding to describe a tree the host is not running.

npm test includes one test that is unlike the rest: test/roundtrip.test.mjs
drives the real browser bundle's call() against the real route handler the
plugin registered, in a single process. It exists because a test double is written
from the same assumption as the code it stands in for, so it cannot falsify it — the way this plugin shipped three different wrong transports while every checker passed. The only pieces not running in that test are the socket and the browser, and the file says so.

The panel is a control surface, not a read-only view

A view you cannot act from is a diagnostic. So the panel carries verbs — but the authority-changing one is deliberately routed around itself, for a correctness reason rather than caution:

Standing autonomy is only honoured when the four gates run, and the authority
gate reads a grant bound to a human message id. A grant written without one
makes den_status report "granted" while every self-initiated run is still
refused. The panel would show a green light over a dead circuit.

So autonomy/request mints a single-use, 10-minute token and reports it. The
human carries that token into the conversation, and the grant is then enforced by
two independent factors: the token must appear in the text of a human message
in the current turn, and the tool call itself must be approved rather than
auto-denied. The token also identifies the message — humanMessageId is optional, because a model driving a tool call has no reliable way to know message ids, and that lookup is stricter than the id path since it requires the token. An agent can mint a token on demand and can cite any id it likes, but it cannot author a user/message, so it cannot manufacture the evidence. A refusal does not spend the token. Schedules are a different risk class and are written directly, because a schedule is explicitly not a permission grant: every run still re-checks authority, budget, quiet hours and pacing.

The panel also reports a deferred heartbeat as a problem rather than as
silence. That matters more than it sounds: an earlier version treated a missing
delivery path as a permanent verdict, so the heartbeat never started and the only
trace was one event — the assistant was not resident while its status looked healthy. It now retries, reports its progress, and records when it recovers or gives up.

Verified Host contracts

These were read out of the running Host rather than assumed, and two of them changed the implementation:

- Approval is a per-agent session policy, and the model is told when it changes. export type ApprovalPolicy = 'ask' | 'never', applied with approval.setPolicy(agent, policy) — "switch one live agent's policy and queue the transition for its next model step" — and read back without applying the default via approval.overrideOf(session). The service description says it "exposes deterministic policy changes to the model through the runtime-context snapshot and switch notices", which is why an approval-disabled session announces itself in the model's own context rather than only in a log. approval.request returns 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable', where 'allowed-once' is the only grant and a missing or throwing answerer normalises to 'unavailable' — fail-closed at the Host level, not only in this plugin. This is the contract that decides whether the human-grant flow can complete at all, so it is recorded as a measured fact rather than inferred from behaviour.
- @deepseek-ai/cordis is not resolvable from a profile's node_modules. A plugin that imports it fails to load, so dsh-den uses only the injected ctx.
- Reading ctx.<service> for a service that is not named in export const inject throws cannot get property "<key>" without inject, and apply never runs. The convention, confirmed against two released plugins: injected services are read as properties (ctx.tools), while ctx.get(...) is reserved for deliberately optional, undeclared services.
- PreToolDecision.displayReason is a localised object ({ en: string, [locale]: string }), not a string. info exists only on the deny branch.
- ToolExecution.agent is optional; signal is required.
- agent/status carries exactly 'idle' | 'running', and .status is the state just entered.
- ctx.tools.guard(...) is documented as monotonic: no guard can force-allow what another guard denied. That is why the hard rules are installed there and not only on the waterfall.
- ASAR note for anyone reading the shipped packages: the archive contains no .d.ts files at all. The only verbatim TypeScript declarations live in the generated dsh-tool-cordis/lib/types/api-catalog.js.

Roadmap

Live verification is tied to an instance, not to a date. The results below were produced against one running Host — pid 46900, plugin instance started 2026-10-13T05:06:55.290Z — and they are only claims about that instance. A later edit makes them stale by construction, which tools/check-deployment.mjs now detects by comparing the instance's startedAt against the newest source mtime, so "the tests pass" and "the host is running this tree" stay separate statements.

| probe | result on that instance |
| check-live-autonomy | PASS — an agent cannot obtain standing autonomy from the live host |
| check-live-grant-bypass | PASS — attacked twice with a real token and the live session id; refused both times, autonomy=null, grantFiles=0 |
| check-live-notices | PASS — 8 checks; the expiry rule holds in the host's own directory, and a dead record no longer silences the same observation |
| check-live-memory | PASS — scenario isolation holds and the host reads the same store |
| check-wake-api | PASS — 8 checks; the live agent carries a callable followup() |
| check-tools-live | PASS — the den_ tools are callable from the assistant's own turn |

gate-audit.json is the seventh artefact and the only one that is not a probe result: it holds the durable record of what the gate refused during that instance's life, with the tool, rule, risk, decision and session but not the arguments.

- D1 skeleton — installable plugin, durable state, status surface (done: bundle registered in a real profile; fiberPhase: active through the Host loader)*
- D2 authority + notices — grant binding, expiry, dedup (done, tested)
- D3 autonomy kernel — heartbeat, the four-gate decision, cold wake (done, tested; the heartbeat resolves its own driver and reads the wake's reply back from the session log — see the three production-breaking defects in DESIGN.md §2.5)
- D4 outbound approval gate — pre-execute listener plus a monotonic guard, outside the assistant's own prompt (done, tested; §2.6 records a fourth defect found in the D6 panel half, where the stand-in was a fake runtime rather than a fake service)
- D5 memory + status UI — scenario-isolated memory domains (done, tested); interactive resident panel (done: registers in the live sidebar, verified through 41 interaction checks and 22 route tests)
- D6 7×24 — supervised survival: killed process recovers with no human intervention, verified over a long run
- Progress: the watchdog distinguishes a wedged Host from a healthy one (StrictHttp; a TCP-listening-but-HTTP-dead Host was previously logged as healthy), and the plugin's own heartbeat now self-heals instead of deferring permanently. The kernel also runs on its own clock with nobody present: after the instance booted at 05:06:55.290Z it began ticking on the 60-second cadence and its lastTickAt kept advancing across separate checks, none of which the kernel was answering. What is still open is a long unattended run — the acceptance criterion is a kill-and-recover observed without a human, not a configuration.
- Residency: "resident" assumes one Host per state tree, and ~/.dsh/den is keyed on DSH_HOME rather than on the profile, so two profiles can share one tree. This was measured rather than assumed: with two kernels over one home, dueTriggers tells both that the same trigger is due — a read is not a claim — and with the first parked inside its delivery, the same check-in was delivered twice. It is a direct consequence of a deliberate ordering recorded at kernel.js: the check-in stays due if the process dies mid-delivery, trading a rare duplicate for never losing one. Under one Host that trade is right; under two it made the duplicate routine.
- The guard: src/resident.js is an atomic-create lease (openSync(path, 'wx')). The kernel is not locked out of starting — it stands down per tick and retries, so there is no state in which every Host declines and the assistant goes silent. A holder is reclaimed when it stops renewing or when its pid is gone, so a restart takes over on the first tick rather than waiting out the staleness window — with one bound: a pid-negative answer no longer authorises taking over a record younger than TAKEOVER_GRACE_MS, because that answer was measured being wrong. Status carries residency, and the panel shows it, so a standby Host is distinguishable from a broken one.
- Measured across processes, because in-process it cannot be. defaultIsAlive(pid) asks the operating system whether the recorded owner still exists, and inside one process both owners are process.pid — so the check is a tautology that an implementation ignoring pid entirely would pass. test/two-process.test.mjs runs real children over one home, and each case is falsified rather than trusted: a second process takes residency once the first has exited (make defaultIsAlive always true and it fails — "a dead owner must not block the tree"); a second process stands down and names the live holder; two processes looking at one due check-in deliver it once (delete the lease and it fails — "and must stand down rather than deliver it"); and a process that is killed rather than disposed leaves its lease on disk and the next process recovers residency and delivers it with nobody watching, which is cold recovery with a real kill.
- The children used to run on a synthetic clock, and that is what hid the branch. With one, the two processes' lease timestamps differ by a factor of thousands, so the record always reads as stale and freshness is never decided by whether the owner is alive — all four cases passed without ever asking. They use the real clock now, and the falsification above is what proves the branch is reached: neutering defaultIsAlive fails exactly the two dead-owner cases.
- The guard, audited in the direction nobody audits: a gate gets examined for being too broad, and this one had been — an edit was refused for containing the text of a rule, twice. The opposite was never checked until the tool table was read against the Host's real registry. web_search is allow by decision, and the justification written into test/gate-coverage.test.mjs was that a credential is caught "whichever tool carries it". For that tool the justification was false: the path rules match paths (.env, id_rsa), so a credential value was invisible to them, and the outbound action rules never ran because a search does not push code anywhere. Pasting a key into a query was allowed, with no decision and no audit line, while the same key through a shell command that uploads a body stopped and was logged. The fix is not to make search ask — that removes a resident assistant's ability to search, and in a profile with approvals off ask is a silent refusal, which is the pressure that gets gates turned off. It is a value rule scoped to the tools whose arguments leave this machine, asking rather than denying because a shape is weaker evidence than a filename. Ordinary searching is untouched, and the scoping is pinned: a rule over shell text would have refused this repository's own credential scanner, because the scanner greps for the shape being looked for.
- The same audit, run against the filesystem: the tool table is checked against the Host's registry, and the secret-path table had no equivalent — which showed. Enumerating the credential stores this machine actually has, including every one the user's own home instructions name, found five that existed and that read was allowed on: their codex/rules file (which their instructions say carries a live token), codex/.sandbox-secrets, tokenrouter.config.toml, kimi-code/credentials, and the container registry config. Two rules guarded paths that do not exist here at all. The table had been written from what credential files generally look like, and a list built from recollection protects the recollection. test/gate-coverage.test.mjs now asserts the stores, so one appearing later fails a test instead of going unnoticed — and the audit caught its own author once more: the gh CLI location written from memory did not exist here, and the one that does is under %APPDATA%.
- The platform the author was imagining: the third instance of the same defect, and the largest. Every outbound rule was shaped like a Unix CLI tool, while this plugin ships to Windows and its shell is PowerShell. Measured: twelve distinct ways to move data off this machine — Invoke-RestMethod, Invoke-WebRequest and its aliases, BitsTransfer upload, a .NET WebClient, mail with an attachment, certutil -urlcache, scp, rsync, ssh, nc, PowerShell remoting — every one of them allow, while the same intent through curl asked. The control cases were allowed too, so the classifier was working; the table was written for somewhere else. The first fix was wrong in a way worth recording: it anchored the remote-host rules to the start of the text, and flattenArgs puts each argument's key in front of its value, so the rule matched nothing in production while a test that fed the pattern a bare command string passed. The rules now aim at remote-looking operands, tests go through classifyCall with real argument objects, and there is a test named for the trap so the next anchored rule fails loudly.
- Where the credential guard stops, measured. The gate matches patterns in tool-call text, so what it can and cannot close is worth stating as a measurement rather than a feeling. Naming a credential store — through read, through a shell Get-Content, or inlined into node -e — is deny. So is write of a script whose body names one, because the path rules run on every tool's argument text and a write body is argument text: the indirect path is closed at both ends, at the read or at the write, whichever comes first. What is allowed is node itself, because a command line carries none of the text a rule could match — the gate cannot read the file. That is a boundary this repository pins with a test rather than pretends away: it is why DESIGN does not claim credentials can never leave the domain, only that the reflexive paths are closed and every decision is on the record.
- Deliberate asymmetry: anything that is not a lost race — an unwritable home, an unreadable record — keeps delivering and records why. A duplicate is recoverable and visible; a resident that silently stopped is neither. Two real defects were found by tests written for those sentences: a sub-millisecond timestamp rounding made a just-written record look like it came from the future and get reclaimed (the crash-window guard silently not guarding), and mkdirSync reporting EEXIST for a parent path that is a file was misread as a lost race, which would have left a Host standing down permanently.
- The mutual exclusion was an argument, and the first time it was measured it fell over. The comment said create-if-absent is the mutual exclusion, so two Hosts reclaiming one stale lease resolve to exactly one. Four real processes released in the same instant over one empty lease produced three simultaneous owners. There are two independent causes, and the second one is the interesting one. First, process.kill(pid, 0) answers "no such process" for a process that is alive: 0 wrong answers in 24,000 probes from an idle parent, 590 in 48,000 (1.2%) from four siblings probing each other under load, 0 in 384,000 in another run — unreliable and condition-dependent, which is worse than a constant error, because it hides. Traced live: a holder one millisecond old was declared gone and its record deleted, while the holder itself went on to report held: true. Second, and independent of the probe: delete-then-create is not mutual exclusion. Two Hosts that judge the same record free both delete it, and the second delete removes the record the first Host just created — so both create successfully and both believe they built the lease. "Exactly one open succeeds" only holds if nobody deletes in between, and deleting is the first step of taking over. Both are fixed: the right to take over is itself a create-if-absent, on a name derived from the record being replaced, so every Host that judged that record computes the same name and only one proceeds; and a pid-negative answer no longer authorises taking over a young record. The cost of the second one is real and is stated rather than hidden — a holder that dies within a second of its last renew waits one heartbeat — which is why two cross-process tests changed to model a restart as it actually happens. The retry idea was measured and rejected: spaced retries (Atomics.wait, 5 ms, four attempts) still produced two owners in 8 of 8 runs, so it stays only as a cheap extra, not as a fix.
- And the verifiers had to be rebuilt, because the race tests were not reliable ones. They found the defect, but with either fix removed a single run still passed — the bad interleaving needs a busy machine. Each fix therefore has a deterministic in-process verifier, and both were falsified by removal: take out the grace and a record younger than the grace is not taken over on a liveness answer alone fails; take out the takeover claim and a second host cannot take over a record that has already been taken over fails. A test that only fails when the machine is busy is not a test.
- Its route is ungated while the shell it lives in is not — by DSH design, and now on the record.
- "Verify passes" and "the running host is stale" were true at the same time. npm run check:deployment exits 1: the host loaded at 2026-10-13T08:43:05.676Z while client/client.js changed at 09:24:00, so the plugin answering live requests was the code from before the fix — and live verification had been reported in between. The guard already existed and even documented this exact failure mode; it simply is not in npm run verify, because it needs a running host, so nothing that ran every round could see it. The running instance now judges itself (status.freshness, computed per request rather than cached at boot) and says so in the panel — older than the tree, restart DSH, naming the file — because the person who can restart it is the one looking at the panel, and a healthy-looking panel is a stale host's best cover. Two independent walks (the host's own loaded files, and this repository) are cross-checked, but only outside the tolerance, since agreement inside it is not evidence. See DESIGN 2.25.
- The staleness guard existed; it was just not on the path a single probe takes. check-deployment.mjs correctly reported the host was running code from before client/client.js was edited, while six check: probes each exited 0 — they describe the running instance, and each was run on its own, so their passes described code that was not on disk. verify:live stops at step 1, but npm run check:notices had no gate at all. The guard now runs as its own process, chained in front of every live probe script with &&, which makes it inherited by every way of running one. Three outcomes and only one is a pass: loaded this tree, loaded older code, or cannot say — and "cannot say" is a refusal, because a probe describes a running host and there is nothing to describe. The pass path is verified against a synthetic host over a real socket, because a gate that refuses everything is a gate that gets turned off, and a structural test fails if a new live probe is added without the guard. See DESIGN 2.26.
- The allow-list trap, a fourth time — and now with a test. src/panel-api.js carries a comment saying a new status() field is a two-file change of which only one fails loudly. Adding freshness failed loudly in exactly that way. So test/panel-payload-coverage.test.mjs compares the real status() key set against the payload and requires every withheld field to carry a stated reason, with a control proving the check can fail — it found eight fields on its first run, one of which was being renamed rather than dropped.
- subagents.sendMessage is the host's wake-up for a child, not for this conversation. Its
declaration steers "one model-authored message to the sender's direct parent or direct continuable
child" and throws when "adjacency is rejected" — den's standing conversation is neither, so using it would fail where the admitted path works. Delivery stays on sessionController.prompt, which the host documents as admitting a prompt after resuming its session and which the log proves was delivered: three of those turns reached the conversation's inbox before it had a model to answer with. Where no session controller exists at all, the words are still appended to the transcript and the missing delivery is named rather than faked.
- Where the host's preset composition comes from, and why this plugin cannot copy it. Its
api-session service resumes with agentOptions: this.agentOptions() and setup:
composition.setup, where the composition is built by a private composeAgent(presetId) on that
service — not a call a plugin can make. The public hook declared for this is
agentPresets.mount(ctx, id), whose ctx the declaration describes as "Agent context from its setup
callback", i.e. the setup argument of agents.create. Whether mounting alone also installs the
preset's capability tree is NOT established, so it is not done here on speculation: the model was the
missing piece the failing turn actually named, and capability composition is a separate question to
answer with evidence.
- The host's own creation path passed three things, and this plugin passed one. Read out of the
shipped implementation: agents.create({ sessionId, agentOptions: { provider, model }, meta: { cwd,
agentPreset }, setup }), where the options come from agentDefaultModel.currentSelection(). The
{{model}} a prompt is assembled with is one of those options, so a preset without them is still a
session with no model. Creation and resume now both carry the host's current selection — inherited, never named here, so den follows the model the human is actually using and follows it when they change it. The plan was to pass only the preset, and the declaration was not enough to show why that would fail: the header field and the model come in through different arguments of the same call.
- The prompt needed two variables, and the log named them one at a time. The deployment persona is
You are a coding agent powered by the {{model}} model. plus Your working directory is {{cwd}}., and
the host resolves them strictly: a session missing either cannot assemble a prompt at all. After the
model was supplied, the next failure was exactly prompt variable "{{cwd}}" has no value for this
assembly (section "deployment:persona-suffix"). The workspace now comes from the human's own session —
published, or through the observeSession observation the host itself uses to read a header — with den's own home as the last resort, and the source is recorded. A record without a cwd is replaced rather than resumed, because a session header is immutable.
- The standing conversation had no model, and the log said so in the host's own words. Three turns
had run and ended with turn/end { reason: { kind: 'error', error: { message: 'prompt variable
"{{model}}" has no value for this assembly (section "deployment:persona-prefix")' } } } — because
agents.create was being called without an agent preset. Every real session header on this machine
carries agentPreset: cordis; the standing one carried none, so the turn died during prompt
assembly and produced no message at all. Creation now passes the host's own default preset
(agentPresets.list(), preferring isDefault), the record remembers it, and a record from before
that — which can never work — is replaced rather than resumed into the same failure, keeping the old id as replacedSessionId. Two lessons came out of the same investigation and are worth keeping: the empty transcript was caused by dead delivery, not by a faulty reader, and the reader that reported "one line, no messages" was decompressing only the FIRST zstd frame of a log that appends frames — it had been describing its own limitation as if it were the file.
- A resumed session carries its whole transcript, and that is ④ — read out of the declaration, not assumed. Session.snapshotEvents(fromSeq = SessionLogOffset(0), toSeqExclusive = this.seq) starts at the beginning by default, and a restored session is constructed from
RestoredSessionOptions { seed: SessionEvent[]; meta: SessionHeader; … } — the persisted events are
the seed. So after a restart the window reads the accumulated transcript rather than only what
happened since it attached. The same pass showed SessionHeader carrying agentPreset, which is
exactly the field the preset fix writes, and that meta.cwd must be an absolute path: the store
throws on anything else and keys storage directories off it, so a relative cwd from configuration is
now dropped instead of failing every creation.
- The creation and resume half of ③ was verified the same way, and one name in it carried ④. The
service contracts say CreateAgentOptions { sessionId; meta?: { cwd?; agentPreset?; … } } and
ResumeAgentOptions { resumeSessionId; … }, and AgentHandle { agent; dispose() } — which is what
this plugin passes, so the standing conversation is created under the id it generated and resumed by
the field the host declares. That mattered more than it looks: a wrong resume field would let the
first message work and break every restart after it, which is the one promise ④ makes. Session.append
was checked in the same pass — append<T extends SessionEventType>(type, data, ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []) with SurfaceOp = 'append' | { op: 'replace'; startSeq; endSeq } — so the fallback's third argument is required, not optional, and its shape is right. Finally, meta.agentPreset exists, and this plugin deliberately does not set it: a standing conversation should start on whatever the host defaults to, and naming a preset from here would be inventing a capability choice the human never made.
- Verified against the shipped declaration, not against a doc summary. The host's own types live
inside resources/app.asar, whose format is a pickle header, a JSON directory, then the file bytes — so the metadata can be read without extracting 121 MB. Two declarations settle the delivery question: export interface SessionPromptRequest { readonly requestId: SessionRequestId; readonly sessionId: SessionId; readonly mode: 'queue' | 'steer'; readonly content: readonly PromptContentPart[]; readonly clientTimeZone?: string } and export type PromptContentPart = { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly mediaType: ImageMediaType; readonly data: string; readonly name?: string } | …. The request this plugin sends is field-for-field what the type declares, clientTimeZone is genuinely optional, and nothing else — no source — may be required, because the JSDoc prose mentions "source metadata" that the type does not have. A prompt is accepted with { accepted: true }, and the caller learns about the turn from the session log, not from the return. Two smaller things were checked the same way and are also worth not re-deriving: the client does not read messageId from conversation/say at all (it only cares that the promise resolves), so a null id there cannot be mistaken for a failure; and PromptContentPart can carry images, which this plugin does not use yet.
- Delivery was built on a method the host never promised. The session log on disk settled it: one
line, the session header, and no turn/start, no user/message, no assistant/message — so the human's sentence had neither been recorded nor answered, even though the session itself had been published and written to disk. The contract explains both halves. Agent declares exactly one member, readonly id: SessionId, and AgentHandle is { agent, dispose }: there is no followup to call, and my guard had been checking for one. What the host does document is the admission path the GUI composer itself uses — sessionController.prompt(request, signal), "Admit one prompt after explicitly resuming its Session", with { requestId, sessionId, mode: 'queue', content } — so the route now asks the host to take the prompt first and only falls back to queueing on the agent, and it reports which path carried the message (delivery: 'prompt' | 'followup') so a window that shows nothing can be told apart from a window that failed. The fallback keeps round 20's append, because a queued prompt writes no event; on the admitted path the host owns that write, and adding our own append there would record the same sentence twice. Still unverified on the real machine: The wake path in createDeliver is converted too: it admits the check through sessionController.prompt first (wake-admit-failed records a refusal instead of hiding it), the agent handle is only the fallback, and the baseline sequence is read from the session store rather than from wake.session — a live driver is not guaranteed to expose it, and Agent declares only id. The kernel's contract is unchanged: a wake that reaches nobody still throws, so it is never charged as "nothing worth saying". It produced a live agent, a stored session id, and an empty transcript — conversation/read said {state: 'live', total: 0} while the session directory on disk held a header and nothing else. The host's sessions contract says exactly who writes an event: persistence is not in the store, "the agent lifecycle attaches a session-log writer to each published session's write handle", and Session.append is how an event joins that log. agent.followup queues an ordinary next-turn prompt and wakes the driver; it writes nothing. The delivery half was there and the recording half was missing, which is the same as a standing conversation that forgets everything the moment it answers. sayInConversation now appends the message and then queues the same object, so the two cannot drift, and appendToSession reports whether the session could take it rather than pretending. Measured warning for the future: the log writer buffers, and sessions.flush(session) is documented as "THE flush entry point" for the durability checkpoint — no flush is called here yet, because the transcript the window reads is the live one, and a durability failure must never be reported as a failed message.
- Where autonomy is revoked is a deliberate split, not an oversight. The window can request* a standing grant — that is the one thing it exists for — while revocation lives on den's own page behind 详情. Chosen on 2026-10-14 over a global revoke inside the window: a control that silently drops every conversation's grant is a large hammer to keep one click away from a surface the human is meant to read, and the page is where the consequences of revoking (notices, schedules, the ledger) sit beside it. The first real message is the human's to send, also their choice — den does not spend a turn to demonstrate that it can.
- The whole path is live except the first turn. On 2026-10-14, after the Host was restarted onto this build, the running instance answered: conversation/read returned {state: 'absent', messages: [], total: 0, truncated: false} and created nothing; a conversation/say carrying only spaces was refused with there is nothing to say and again created nothing; status carried dailyTurnBudget: 24 beside kernel.running: true and a ledger of two turns. The read path, the write guard, the budget field and the heartbeat are therefore measured on the real machine rather than assumed. What is still unmeasured is the one thing the turn budget exists for: the first real message, which costs a model turn — and ~/.dsh/den/conversation.json did not exist at that moment, so no standing conversation had been created yet.
- A half-reloaded plugin is its own state, and it needs a sentence rather than an error. After the window moved to the standing conversation, the page was already running the new client half while the Host was still running the half that had never heard of conversation/read: the window printed unknown action "conversation/read", and the shut pill — which cannot see a budget field that older half never sends — fell back to 自治已关 while the ledger on that same payload showed two turns spent. Both sentences were technically about a field, and both were useless to the human reading them. The window now tells an unknown action apart from a real failure and says 重启 DSH 后这里就是对话 with its message box disabled, and the pill says 宿主待重启 instead of guessing at policy. The version probe is the absent field: every Host half that has the budget sends it, so its absence means older. Four checks pin both directions — named when the field is absent, not named when it is present — because a signal that fires without cause is as misleading as one that never fires.
- The GUI has one den panel with two intentional entry points. The current client registers dsh-den.status in both main and sidebar.panellist (order 245), so the sidebar row opens the same panel that the floating desktop pet represents. shell.overlay keeps the compact dsh-den/den-mini resident surface at order 40; sidebar.toggle.badge remains empty, and the existing conversation dock keeps its own occupants. This is a discoverable sidebar entry plus a small always-available pet, not three independent den implementations. The offline checker and the live topology agree on the slot contract; a client change needs the page loaded, while a Host restart is used here to ensure the desktop bundle is fresh. What this paragraph does not say is anything about pixels: the conversation inside the window has not been pixel-inspected in this session, so the rendered spacing still needs a visual check in the DSH window.
- The shut pill reported a heartbeat it could not see, and my first check agreed with it. The pill is the one den surface that is always on screen, so what it says is what the human believes about den working while they are away. It read status.running — a field the panel's statusSnapshot has never had, because the host reports kernel.running — so the running/not-running branch always took the same side, and a heartbeat waiting for a delivery path (a fact the page shows and the pill did not) was never distinguished at all. Two things kept it invisible. The offline double and the code agreed about a shape the host never sends; and the check I wrote first asserted 常驻中, which appears in both branches — 常驻中 · 心跳运行中 and 常驻中 · 已停 — so it passed while the pill was reporting the wrong one. The pill now reads kernel.running, says 等待投递 when the heartbeat is deferred, and reports the daily budget; dailyTurnBudget had to be added to the panel payload first, because a payload is an allow-list and a new status() field is invisible until it is named there. The checks now assert the half that decides.
- A check that came due in a conversation that no longer existed used to die, so den now has a conversation of its own to run it in. createDeliver resolved its driver from trigger.sessionId, and when that session was gone it threw no delivery path for this wake — the right call for a schedule the human is watching, the wrong one for a resident assistant, whose whole point is that the check runs while nobody is looking. A wake with no driver now runs in den's standing conversation (ensureConversation), created through the agent lifecycle so it persists across hosts, and a wake-standing event records that it ran somewhere other than where it was scheduled — "the check ran" and "the check ran where you asked" are different facts. The order of the gates does not change: the kernel consults the daily budget and quiet hours before delivery, so an unattended wake is rationed exactly like every other one, and the failure path is untouched — a host that cannot create the conversation still throws and still records wake-unavailable. One thing is unmeasured: agents.create is called without an agentPreset, so what that conversation's agent can actually do is whatever the host defaults to, and that has not been observed on the live host. The reply is visible in the floating window, so the first real wake will answer that question in public rather than in a log.
- A loopback port is reachable from every page the browser has open, and nothing here checked that. Measured on 2026-10-14: POST /dsh-den answers 200 with no token at all — the same pair the paragraph above records, from the request side rather than the screenshot side. The argument above reasons about local processes, which is the right frame for the filesystem; it is not the frame for a port, because a browser marks a request from one site to another with Sec-Fetch-Site: cross-site, and this route can make den speak. foreignRequest() in src/panel-route.js now refuses a cross-site or same-site request, a request whose Origin is not this host, and a POST that declares a non-JSON content type — and it does so before the body is read, so a foreign request cannot even be parsed into an action. The first version of that rule was too broad and the end-to-end route test caught it: it refused a POST that declared no content type at all, which is what test/panel-actions.test.mjs and a curl probe both send, so it broke 28 passing tests. test/panel-route-guard.test.mjs pins both halves — the refusals, and the callers that must still get through.
- The panel asked for a conversation in a seat that never had one. The first pixel-level look at the panel came from a screenshot the user sent: it read standing autonomy: unknown for this view and no active conversation while a conversation was open, and its add-self-check button was greyed out. Three independent live measurements said why - the root main slot's own catalog states that only the reserved conversation key receives a Session binding and its standardProps carry no sessionId; the live sessions service has no current() and no ctiveId(); and across 21 plugins in the ecosystem the only occurrences of an ctiveId shape were my own code and my own test double. A unit test in this repo, the bundle declares sessions, which it reads for the active conversation, had been requiring the dead path to exist - a green test is not evidence that the thing it tests can run. The grant control and the self-check form now register into conversation.input.dock, whose live catalog lists sessionId among its standard props and which sits directly above the message box - which is where the minted token has to be pasted anyway. See DESIGN 2.24. Trying to screenshot the panel turned up the pair: same origin, same instant, GET / answers 401 with "dsh web authentication required" while GET /dsh-den answers 200 with the full status document. The host contract says why rather than leaving it to interpretation: webServer.register(route) takes no authentication argument, and the shell's token gate lives on a different service (connection.authorizeIndex / requestRejection), which the index passes through and a route does not. Severity, measured rather than feared: the host binds 127.0.0.1, and everything this route reports comes from ~/.dsh/den/, which any process running as this user can already read — so it exposes nothing the filesystem does not. The shell is gated because it carries every session's conversation. It is still worth stating, because a gate open on one side of an origin and shut on the other is surprising, so it is now a live assertion in check-deployment.mjs: the shell must be 401/403 and the route 200, and if DSH ever gates plugin routes that line fails and DESIGN 2.18 gets rewritten. Not done, and why: gating the route here would be unverifiable — the hook exists, but the panel fetches from the browser and the authenticated page cannot be reached, because it lives in the desktop client's Electron process while browser automation drives Chrome.
- The panel's pixels are still unmeasured, but it is no longer only topology. The client Inspect provider reads the live page: the main slot's occupants include dsh-den with key dsh-den.status and active: true — the client half is mounted and active in the real page, not only in the offline doubles. Reaching pixels needs the token the Electron process holds, or one authenticated load in Chrome.
- A stored trigger that cannot be scheduled used to be reported as healthy. isValidStored checked the envelope — id, sessionId, kind, title, prompt, enabled, fireCount — and none of the fields nextTriggerAt reads. Measured with four such records (no everySeconds, no atTime, no weekday, no at): listTriggers reported valid=5 invalid=0, and a query ninety days out returned only the one healthy trigger. A reminder the human is relying on never fired while every surface called it fine — and the docstring already promised these would be "reported and skipped", which they were not, because they never failed validation. The new isSchedulable validates exactly what the schedule reads, deliberately no more: requiring a field here would retire every trigger written before it existed, the same harm pointing the other way. invalid was already surfaced by status.triggers.invalid and the panel's invalidCount, so fixing the judgement made them visible with no UI work. The test carries a reverse control (four well-formed shapes must stay valid and schedulable) or "reject everything" would pass it.
- And a claim in that file was stronger than the code. The "catch up in whole steps so a host that was down resumes on the original cadence" branch only runs when the caller asks for a moment later than one step past the last fire. Both callers in this repo pass lastFiredAt ?? 0 — the same value the arithmetic starts from — so base + step > after is always true and it never runs in the shipped system. Measured: +600000ms asked the way the callers ask, +10800000ms asked the way the unit test asks, a 170-minute phase difference. That is deliberate for dueTriggers — asking "after now" would push a check-in missed during downtime into the future and skip it, the opposite of what capability 2 promises — and the cost is that the cadence re-anchors to the wake that actually ran. The comment says that now. The panel's judgement call (nextCheckAt can therefore render a past time) was left alone this round: for a once trigger, showing the moment it should have fired is also defensible, and UI semantics do not get changed on an unsettled judgement call.
- A rare failure was undiagnosable, and now is not. One full-suite run failed with timed out waiting for ...round-0-racer-2.json.ready after 30,395 ms and child exited 2147483651 — and those two messages between them say nothing, while every other run was 279/279 green. Three causes stacked: the child's stderr was discarded by stdio: 'ignore'; the wait polled only for a file, though readiness is announced before the racer's spin, so a missing file means the process died rather than that it is busy; and the exit promise's rejection was swallowed, so a fast informative failure became a thirty-second mystery. The child now logs to a file (a file redirect, not a pipe — the confined sandbox refuses pipes, and ignore discards the evidence), and the wait races against the children stopping. Pinned deterministically with a --crash mode rather than hoped for: 30036 ms → 147 ms, and the message goes from a missing filename to racer exited 3 — its log: deliberate startup failure…. Three removal falsifications, each failing only its own new pin. What this does not claim: the underlying flake did not reproduce again this round, so the concurrency defect is not found — only made to arrive with its reason attached. DESIGN 2.20 and 2.21 have the numbers.
- The memory that got said twice was the first one to become unreachable. remember refreshes a re-learned fact's createdAt in place — that is its documented contract — and recall scored entries.slice(-200), the last two hundred in file order. So a memory kept its original position, could be the newest thing in the scenario (entriesFor()[0]) and still be invisible to recall forever, while den_scenarios went on reporting entries: 250. Measured on a 250-entry scenario: the needle was the newest by createdAt and at file position 0; recall found it false, considered: 0. After the fix: true, considered: 200. That direction is backwards for a 7×24 assistant — the memory a human bothered to repeat is the one that disappears first. The second half of the fix matters more than the first: candidates now come from createdAt, but the window still silently drops content, so den_recall reports stored / considered / scored, and stored - considered is exactly what it left out. considered had been scored.length — the match count under a name promising the looked-at count, which printed 0 when two hundred entries had in fact been scored. The window was not removed: scoring all five hundred is a cost bound, and the entries outside it are what eviction would drop next. So the test is pinned from both sides — the newest must be found and the oldest must not, using an entry with a token no filler shares and a query it matches perfectly, so only the window can explain its absence. Three removal falsifications, each failing only its own test. Also removed: a recalls field that both branches of remember wrote and that nothing in src/, src/client/, test/ or tools/ ever read — a field named for an event that never happens. DESIGN 2.22.
- And one falsifier of mine reported a false green, recorded because it is the failure mode this project keeps hunting. All three removal falsifications "passed" on the first run, while the same mutation run on its own failed the very test it was supposed to break. The fault was in my command line: --test-name-pattern=$pattern unquoted, so PowerShell split a pattern containing spaces into several arguments. The test really did run, and really was the right one — and it reported success. A falsifier that turns green because the command was malformed is worse than no falsifier, because it manufactures the confidence it exists to remove. The rule now: apply the mutation, confirm it is on disk, and assert that exactly one test ran and that it is the intended one. Redone: tests 1 / fail 1 each, one mutation each, source restored by sha256. DESIGN 2.23.
- Still not covered: the lease is best-effort mutual exclusion, not a distributed lock. The grace bounds how much one wrong liveness answer can destroy; it is not a claim that the probe is trustworthy, and if a wrong answer outlives the grace the takeover still happens. Closing that would mean dropping liveness from the takeover decision entirely and letting a restart wait out staleMs — a trade this round did not make on the user's behalf. DESIGN 2.17 has the traces and the numbers.

7×24 in two stages

| Stage | Approach | Coverage |
| A. This machine | Supervise the Host so it restarts itself when killed; the den rides on it | Whenever the machine is on |
| B. Always | Run the Host on a server; the self-contained state tree moves wholesale | Including while your machine is off |

Stage B carries one hard prerequisite: ~/.dsh/.credentials.yaml is plaintext. Long-lived credentials must not live on a remote host — use short-lived, explicitly scoped grants instead.

Relationship to prior work

Built independently, with two projects read as read-only references for mechanism and hard-won lessons (no code is copied):

- ttmouse/dsh-muse — human-bound autonomy, keeper re-arming, expiring mailbox
- 86cloudyun-afk/dsh-bot — multi-agent isolation, memory domains, workspace UI

Neither combines standing autonomy with multi-agent structure; that intersection is what this project is for.

This is an honest positioning, not a green-field claim. dsh-muse publishes its own docs/gaps.md, and the top items it lists — a memory seam, event-source ingress, daemonisation, and a mandatory outbound approval point so that credentials never leave the trust domain — are exactly the areas dsh-den implements. Read dsh-den as a more careful implementation of gaps a released competitor left open, not as a capability that did not previously exist.

With the outbound point's scope stated rather than implied. dsh-den implements the approval point; it does not implement "credentials never leave the trust domain", and it does not claim to. The gate classifies tool calls by matching patterns against their arguments, so it closes the reflexive and direct paths — a credential-shaped read is refused outright, an upload command is stopped and escalated — and it records every decision durably. It is not a boundary against an adversarial agent: the gate runs in the same process as the agent, which has filesystem and shell access, and a constructed path matches no pattern. Its value is that the costly actions stop and are seen, not that they are impossible. DESIGN 2.14 has the measured half and the unmeasurable half side by side.

License

MIT

The approval gate can be switched off

The gate classifies every tool call and turns risky ones into ask decisions. Where the host has
approval prompts disabled, an ask is not a question — it is a refusal — so the gate can stop tool calls belonging to sessions that have nothing to do with den. One file decides, read when the plugin loads:

{ "approval": "off" }

at <den home>/approval.json (~/.dsh/den/approval.json by default). Missing, unreadable, malformed
or any other value leaves the gate on: only an explicit off disables anything, and it disables
both the ask waterfall and the hard-secrets deny. den_status reports which state is in force as
gate: mounted | not-mounted. It takes effect when the plugin loads, so a restart, not a tool call.

Watching the other conversations

A resident assistant that only ever answers when spoken to is a chat window. den also looks: on every
heartbeat it scans the session store and reads the tail of any log that changed.

- Cheap. The change signal is size + mtimeMs from statSync, and a changed log is read through
one readSync into a fixed 256 KB buffer. The largest log on this machine is 32 MB; nothing here
ever decompresses a whole one.
- Quiet at first. A session den has never seen is adopted, not accused. Otherwise the first scan
would report every historical failure on the machine as news.
- Not intrusive. den never writes into another session, and never wakes one. What it finds becomes
a one-shot wake in den's own conversation, where the same four gates apply: daily budget, quiet
hours, pacing, and the human's standing grant. Without a grant the wake is refused and recorded, not
delivered — which is the intended answer, not a bug.
- Visible. den_status carries watch: sessions watched, changes seen, how many wakes were
queued, and the last error text.
预览分享卡片长什么样 ↓dingchenhui0618-arch/dsh-den
分享 · DeepSeek Harness Hub
dingchenhui0618-arch/dsh-den

全天候值守,替你读遍会话与工作频道并起草回复

回到顶部