dingchenhui0618-arch/dsh-den ↗★ 0

dsh-den

7x24小时常驻个人助理插件 适合需要后台持续运行、自主记忆并在跨越信任边界前主动询问的常驻助理。

套件
dsh-den
相容性
待驗證
版本
0.1.0
授權
MIT
最近更新
2026年10月16日

安裝

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:dingchenhui0618-arch/dsh-den

dsh-den

A 7×24 resident personal assistant for DeepSeek Harness. Not another chat window — an assistant that stays on, keeps its own memory, works while you are away, and stops to ask before it acts outside your trust boundary.

Status: the core is implemented, tested, and active in a real profile. This line deliberately carries no test count: a hard-coded one rotted repeatedly (307, 309, 316, 319, 320, 322, 323, 326, 327 — each time while the suite was in fact green), and the last rot happened one round after a "fix". So the line states what is stable and npm test prints what moves. The plugin is loaded inside a real DSH 0.2.0-rc.2 desktop profile — it is in that profile's dsh.profile.bundles, and the running host reports it as fiberPhase: active — registering 16 model-facing tools, the approval guard, the heartbeat, and an interactive status panel. Verified against the running system rather than only in tests: the heartbeat ticks on its 60-second cadence, and the panel is an active occupant of both sidebar.panellist (id: dsh-den.status, order 245) and the main panel (key: dsh-den.status) — read from the live browser page through the host's read-only Slot inspector, not from the plugin's own registration call.

Those live sentences were re-established on 2026-10-14 against the instance reporting startedAt: 2026-10-14T02:10:43.000Z, whose own freshness verdict is known: true, stale: false and which loaded this tree 1000 seconds after its last source edit. npm run verify:live exits 0 against it and all seven live probes pass: the heartbeat ticks on its 60-second cadence, the route answers, an agent cannot obtain standing autonomy from the live host, the approval gate is the only path to authority, the expiry rule works in the host's own directory, scenario isolation holds against the same store the host reads, and disk evidence shows the den_* tools really were invoked. The earlier instance, 08:43:05.676Z, is why this paragraph exists: after the panel fix landed at 09:24:00 it was still serving the previous panel while six live probes went on printing passes — each one run on its own, and each one describing code that was no longer on disk. Two things now make that state impossible to report as a pass. The plugin reports status.freshness: the newest source edit beside it against its own start, computed per request rather than cached at boot, with "cannot tell" as its own answer instead of a healthy default. And every live probe script is chained behind tools/require-fresh-host.mjs, which refuses to run it when the instance is stale or cannot say.

The panel change is verified by what the page asked for, not by pixels. At 09:52:28 — 141 seconds after that boot — a panel-autonomy-requested event reached the route carrying a real conversation id, session-95cc8a8d-…. The previous panel had no conversation to pass (resolving the active session was the defect, and it passed null), and the host is confirmed to be running the new code, so only the dock can have sent it; the probe's own requests are identifiable by their literal session ids (live-autonomy-probe, grant-bypass-probe). Two independent facts — the host loaded this tree, and a request only the dock can make arrived — are firmer evidence than a screenshot, and a screenshot is still not claimed.

The panel was taking most of the screen, and that was a design error rather than a layout one. The dock above the message box rendered the entire self-check creation form — title, prompt, interval, submit — permanently. It was there for a reason: the dock is the one seat the shell hands a real conversation to, and schedules/create refuses without one, so the form had to live somewhere a conversation exists. But the capability needs a conversation; it does not need to be on screen. The dock is now one line of state — autonomy, its two controls, and a one-line New disclosure — and the empty state names the cheaper path: say «check CI every 30 minutes» in the conversation and the den adds it. That this is small is machine-checked rather than asserted: tools/check-client-interaction.mjs requires the dock to render zero inputs and no submit button until the disclosure is pressed (it rendered three inputs and a submit button before), then checks that the form still sends the same payload once opened. The logic is the one the reference assistants this project is measured against are built on — state at a glance, one thing at a time, and the human talks instead of filling in forms — stated as a reading rather than a quotation, because this plugin's own gate keeps web_fetch fail-closed in a profile with approval: never, so those pages were not readable from inside this session.

The admitted wake happened — at the first heartbeat after the self-check came due. On 2026-10-14 the panel minted a request, that token came back as a human message, and den_set_autonomy recorded {ok: true, autonomy: true, boundToMessage: "b7b68b9f-…"} for session-95cc8a8d-…. A self-check was then created with the 300-second floor; it came due at 02:34:26, and the next tick — 02:34:43, one cadence later — spent a turn and spoke: the ledger went turns 0 → 1, spoken 0 → 1, notices 1, and the trigger was consumed. The host recorded the event itself, {"type":"wake-spoke","triggerId":"76e58cc0c47ef5…"}, and npm run drill:after returned RESULT: PASS (exit 0) behind a guard that had just confirmed the instance loaded this tree. Three details make it more than one successful run. The same call used to be auto-refused: the gate audit still lists historical den_set_autonomy → unknown-tool → ask entries from the instance before the fix, and the successful call is absent from that list — it was classified as the den's own tool. That classification is the fix described in DESIGN.md §2.28–§2.30: the approval policy in this profile is not its own switch but a side effect of the permission preset (danger-full-access implies approval: never, where a gate ask means never, not later), so for a while every den tool that was not read-only was unreachable — memory, self-checks and authority alike. The answer was to classify den's own tools as its own, each with a stated bound, rather than to weaken the credential, shell and outbound rules above that table; those rules still refuse this session's own calls when they should. The counters are read separately on purpose: turns is charged after delivery and spoken only when the wake actually says something, so a wake that was admitted and judged not worth saying would have shown turns 1, spoken 0 and failed the drill; both moved, so this was a wake that spoke. And the grant is bound to a message, not to a click: boundToMessage names the human message that carried the token. What is still only verified offline: a wake after a restart. Triggers, grants and the ledger all live on disk and survived the restart that moved this host to 02:10:43; test/cold-start.test.mjs grants through the real token, stores a trigger, moves the clock past a restart and watches the wake fire — but the joined event (restart, then a wake) has not been observed live, and neither has its negative (a due trigger with no authority staying silent). Both need a human act, and npm run drill:before / npm run drill:after is how either would be measured. DESIGN.md §2.35 has the event chain and both gaps; §2.34 is the defect this drill found in itself the first time it ran for real.

The wake spoke and nobody was told — the defect a reader of this panel found. The page reported Pending notices 1 / 1 and Today's self-initiated turns 1 while the human had received nothing, and the strip above the message box still offered a spent token with the instruction to paste it. Both had the same shape of cause: a surface reporting state it had no way to update. pendingNotices knew only "deliverable" and "expired", so a notice that had been shown could not stop being pending — the count could never reach zero — and the wake's own implementation queues its text (queueNotice) with no human-facing surface rendering it: the count said something happened and nothing said what. The fix is at both ends. A notice carries deliveredAt, only an explicit acknowledgement sets it — inferring "delivered" from a render would make the count lie in the opposite direction — the status snapshot carries the pending text, the dock renders it where the human already looks, with a read it control, and the minted token is shown only while it is still an unspent invitation. DESIGN.md §2.37 has the measurement and the two-sided fix; both halves are pinned by tests that fail if either is missing.

And it reproduces from this repository alone. A fresh git clone of the published repo — no node_modules, because the package declares no dependencies at all — runs the whole suite green:

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

The four files the residency work added were looked for in that clone by name — src/resident.js, test/resident.test.mjs, test/two-process.test.mjs, test/support/resident-child.mjs — because the failure this proof exists for is a source file that is on disk, is imported by the code, and never made it into the repository. A bare lib/ in .gitignore did exactly that once, and every local test passed while the pushed repo was broken.

That is the claim worth making precisely: not "it works on my machine", but a third party with a Node binary and nothing else can check this. npm run check:source-tracked asserts it on every run: every source file on disk is tracked by git, and the ignore rule that hid one is named.

A transcript is not bookkeeping. The number above was once edited from 240 to 241 to 252 across three commits while the last actual clone run had printed 237 — a public claim with no command under it, which is the defect this repository keeps finding in its own work. The number now comes from a run at the pushed commit, and the way to keep it that way is to re-run the proof rather than update the count.

The wake delivery path was broken and is now fixed — that history is worth reading, because the test suite was green throughout. The plugin delivered wakes with subagents.sendMessage(agent, trigger.sessionId, …), where agent is the target conversation's own driver. The host admits a send only when the sender is the target's direct parent or direct continuable child — authorizeLineage requires parentSession === parent.id — so the call asked a session to be its own parent and every admitted wake in production died with delivery requires the exact live parent agent. Five tests covered that path and passed, because the fakes granted any delivery at all: the double was lying about the calling convention, not the service.

Delivery now goes through Agent.followup, which the host documents as "queues an ordinary next-turn prompt and wakes the driver" — send(message, 'next-turn', true), splicing into the inbox and opening a turn boundary. No lineage is involved, the wake is visibly a queued turn in the conversation, and it durably becomes a user/message that the reply read-back pairs with the assistant's answer. subagents is no longer read anywhere.

The delivery surface is now verified on the running host, not just in the harness. npm run check:wake-api asks the live Host what it actually offers and reports:

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.

That check exists because the defect it guards against was an assumption about an API, and it is placed where the assumption can be measured: agents.get/agents.resume are declared to return Agent, and Agent is declared as { readonly id }. followup appears in no service signature — it is a method on the live agent object the factory builds — so the surfaced contract is narrower than the runtime object and no amount of reading signatures answers "will delivery work on this Host". The probe reports sawLiveAgent: false rather than a pass when no agent is live: I could not look is not it is not there.

What is still outstanding is one human step, not a defect. No admitted wake has ever been delivered, because admitting one requires standing autonomy and no human has granted it. That single drill is the last unverified path in the project; everything on either side of it is measured. See DESIGN.md §2.7 for the code evidence and §2.8 for how the fakes were made to stop hiding it.

Capability 4 is verified end to end, and the way it was verified is the point. The gate's secret rule matches on path, and it is installed as ctx.tools.guard, so it intercepts every tool call in the profile — including the assistant's own. That makes a zero-risk live test possible: read a credential-shaped path that does not exist. If the gate works the call is refused before the filesystem is touched; if it does not, the result is ENOENT and nothing leaks.

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

That sentence is src/gate.js verbatim, and the host's own service event stream recorded the decision alongside it:

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

Two more entries in that stream corrected a misattribution of my own: outbound:curl-upload and outbound:recursive-delete, both ask. Two tool calls I had reported to the user as "the user rejected it" were in fact denied by this plugin's own gate — classified ask, and a host without an approval facility turns ask into a denial. The gate had been shaping the session the whole time.

Later, on 2026-10-14, the loopback half of that first rule was allowed through — and only that half. Asked about the boundary, the human answered "只放行本机", so decide() now returns allow for an outbound: rule whose destination is a named loopback host (127.0.0.1, localhost, [::1], with or without a port). Three bounds keep that permission as narrow as the sentence that granted it: the rule must be an outbound: one, the destination must actually be written out — a missing or unparsable destination still asks, which is what stops text that merely mentions an upload from travelling as permission — and the exception sits after the deny branch, so an allowed destination can never rescue a denied action. The argument that made the rule fire on both halves (a loopback service can relay) has not been disproved; it is now recorded as the human's call rather than the plugin's. test/gate-loopback.test.mjs pins every one of those bounds by calibrating against the gate's own thresholds instead of copying them.

The same evidence exposed a defect worth naming, because it is a whole class rather than a slip: onDecision announced decisions through noteEvent (the in-memory ring the panel shows) and never called recordAudit, while gateAudit() read gate-audit.json — a file no code wrote. So the panel's gate surface reported "no decisions" permanently, including while three real ones had just been made. recordAudit wa