brandonzyy/dsh-plugin-verispring

dsh-plugin-verispring

dsh 三角色会话架构门禁 + manon 本地图谱组件(bundle:派遣规范门 / scout 常驻 / 地图前置 / 模型钉死 / 提交门 / 领导面限定 / manon 桥接)

Uncategorized

dsh-plugin-verispring

Verispring three-role session architecture plus a bundled local knowledge graph (manon), substantially improving main-session context and reducing premium model API costs. 中文 | English

What it enforces

# · Gate · Behavior 1 · Dispatch spec gate · subagent / subagent_fork requires the dispatch spec content to have appeared in the session (a read result containing the spec's first-line marker; state is persisted in the session log and survives restarts) 2 · Scout identity (dispatch intent) · The role is decided by the `description` prefix the leader writes: a dispatch whose description starts with 测绘智能体 or scout is a scout dispatch, anything else is an executor dispatch. Reachability only decides whether the dispatch may start, never the role: while a reachable scout exists, another scout dispatch is denied with a send_message reuse pointer (a scout waiting for a question showing finished/inactive is normal — send_message cold-resumes it); when no scout is reachable, an executor dispatch is denied with the scout-missing rule and three routes — send_message the previous scout, re-dispatch with a scout-prefixed description, or have the scout build/recheck the map first. It is never silently re-roled into a scout. Every scout dispatch gets a briefing (role, read-only duty, the unreachable previous scout, and the full repository map when present); only a successful scout start records scoutId, executor dispatches never touch that field. Child roles are persisted at spawn (session-state/.json) and restored from the record, the session title, and the recorded scout id after reloads 3 · Map first + baseline · Non-scout dispatch requires /.project-map.md (project root = .git/.manon present; otherwise fail-open); a write/edit of that file registers the current HEAD as the map baseline, and dispatch is denied while HEAD has advanced past it (the denial points to a scout incremental recheck plus a full write rewrite to re-register). A map without a registered baseline is denied too — no fail-open, so a stale map cannot stay valid forever. Fail-open only when CASEOS_SCOUT=0 or HEAD cannot be read 4 · Brief lint + executor cap · The executor brief is a result description, not a research request: briefs containing research wording (BRIEF_LINT_TERMS in gates/dispatch-gate.js) are denied with directions to send_message the scout instead — negated mentions are exempt (a negation within 6 characters before the hit, e.g. 不要通读实现 / 禁止先调研). Running executors per leader session are capped (default 8); exceeding it is denied with "end the turn and wait for receipts". Both have kill switches (CASEOS_BRIEF_LINT=0, CASEOS_MAX_EXECUTORS) 5 · Pinned model · Routes are enforced at runtime per role: a scout dispatch uses the panel's scout route, an executor dispatch the executor route (the role comes from the description prefix); explicit provider/model on the call is ignored. subagent_fork is always denied 6 · Commit gate · git commit requires a prior read of ~/.dsh/specs/代码写作规范.md; the shape is forced to -F file -- explicit paths, with timeout/env/nice/nohup/stdbuf style wrappers seen through 7 · Leader scope · The leader's tool face shrinks to the review five (bash/edit/write/read/read_image) plus the dispatch face (subagent/send_message/interrupt_agent/list_agents/release_subagent), intersected with the tools that actually exist in the leader's view (desktop serves them from the preset layer, headless from the root layer); dispatch tools are shadowed by slim definitions, and those model-visible definitions are frozen per session (see "Per-session frozen tool surface"); subagent-base is unconditionally denied at the root layer (if the routing executor ever breaks, the model has no fallback to sneak through). Scout and executor subagents get the full face of the verispring-subagent preset; a scout's edit/write are hard-denied at runtime — gates/dispatch-gate.js matches the scout role from the queued dispatch plan on agent/created and calls rtc.tools.restrict({ deny: ["edit", "write"] }) on the child's own scope, so the tools are absent from the scout's tool list (executors and the leader keep them). Kill switch CASEOS_SCOUT=0 drops the scout part of the division clause and the restriction 8 · Leader guard · Two kinds of runtime handling. Denials (unchanged): while a child is running, sleep/loop-waiting in bash is denied, and a second list_agents in the same turn is denied. Hints (since 1.3.66): edit/write outside the whitelist (.project-map.md, paths containing /reports/, ~/.dsh/AGENTS.md, plus CASEOS_LEADER_WRITE_ALLOW prefixes) and bash change commands — git commit/push/merge/rebase/restore/stash (only git stash list / git stash show stay silent), npm/pnpm publish/install, sed -i/perl -i, rm -rf, workspace redirects — are no longer denied: the call runs as usual and one notice is appended after the tool result via the host tools/post-execute additionalContexts ("clear-boundary code and writing tasks go to an executor subagent; the leader does small fixes, the map, reports and verification writes"), at most once per turn, recorded to the monitor as leader-write-hint (rule + summary). Subagent sessions are unaffected. Kill switch CASEOS_LEADER_GUARD=0 9 · Scout relay · The three loops that used to exist only in the prompt: ask_scout lets an executor hand a location/fact question to the resident scout and waits for the fresh answer (sender is the leader agent, since sendMessage authorizes by lineage; the answer is correlated by marker from the scout's session events, so a scout that settles right after answering still delivers it, and every wait round yields through a real ≥250 ms timer that is cleared when the wait ends); the closing "结构增量" (structure delta) section of a non-scout child is forwarded to the scout automatically; a failed child (error/aborted/refusal) writes a checkpoint that the plugin attaches when the leader continues it. Non-scout subagents get an injected ≤4-line locator discipline (ask_scout when a scout is reachable, "write it into your report" otherwise) plus the mandatory structure-delta section. Kill switch CASEOS_SCOUT_RELAY=0; CASEOS_ASK_SCOUT_TIMEOUT_MS bounds the wait 10 · manon bridge · Resolves the local graph CLI (MANON_KG_BIN > bundled manon/manon-kg > legacy ~/manon/manon-kg) and, when present, injects per role: the scout gets the full surface (commands, sourced invocation rule, ownership discipline), the leader gets one line ("graph facts come from the scout"), the executor gets one line — manon is scout-owned; leader and executors never invoke it directly; semantics in ~/.dsh/specs/Manon调用规则.md. The load-bearing part is the invocation rule: dsh's subprocess service strips KEY/TOKEN-shaped env vars from children (anti-leak), so a bare manon-kg sync always fails with a missing key — the injected form sources ~/.dsh/manon.env inside the bash command first (the scrub only strips the first hop; grandchildren inherit freely). Secret values never pass through or get printed 11 · Secret hygiene · Credential/env files (manon.env, verispring.env, project .env*) cannot be read via read or echoed via cat/sed and friends (matched per command segment, so the sanctioned source prefix and pipes on manon output are not false positives); whole-environment dumps — bare env/printenv, ps eww, /proc/*/environ, docker inspect without --format — are denied 12 · Runtime monitor · Role drift, gate denials, scout reachability changes and subagent start/end (role + route + stop reason, recorded only after a successful start) are appended to ~/.dsh/verispring-monitor.jsonl, so audits no longer require decompressing session logs

Environment variables

Variable · Default · Effect CASEOS_SCOUT · on · 0 drops the scout part of the division clause, the map freshness/baseline gate, and the scout-missing denial (executor dispatches are then allowed without a scout) CASEOS_BRIEF_LINT · on · 0 disables research-wording denials on executor briefs CASEOS_MAX_EXECUTORS · 8 · running-executor cap per leader session (0 blocks every executor dispatch). The default equals the host's own maxActiveSubagents default of 8 (the scout counts toward the host limit), so with the scout running the 8th executor may be refused by the host with ACTIVATION_LIMIT_REACHED CASEOS_LEADER_GUARD · on · 0 disables the leader runtime denials at assembly time CASEOS_LEADER_WRITE_ALLOW · empty · :-separated extra path prefixes the leader may edit/write CASEOS_SCOUT_RELAY · on · 0 unregisters ask_scout and stops forwarding/checkpointing/prompt injection CASEOS_ASK_SCOUT_TIMEOUT_MS · 600000 · how long ask_scout waits for the scout's answer before returning a timeout CASEOS_MEM_GUARD · on · 0 disables the host memory watchdog at assembly time (no interval, no listeners) CASEOS_MEM_CHECK_MS · 15000 · host heap check interval CASEOS_READ_GUARD · on · 0 disables the whole-file read guard (tools/pre-execute denial of oversized whole-file reads) CASEOS_READ_GUARD_MAX_BYTES · 2097152 (2MiB) · largest file a whole-file read may touch; larger targets are denied with a segment-read prompt CASEOS_READ_GUARD_ALLOW · empty · comma-separated path prefixes the read guard always allows (~ expanded, directory-boundary matching) CASEOS_MEM_SOFT_BYTES · ceiling × 60% · soft heap threshold: non-blocking release hint to the leader + pruning of the plugin's own indexes (2.4GiB on a 4GiB ceiling) CASEOS_MEM_HARD_BYTES · ceiling × 80% · hard heap threshold: idle-state mem-guard-skip + restart prompt, never an exit (3.2GiB on a 4GiB ceiling) CASEOS_HOST_WATCHDOG · on · 0 disables the out-of-process watchdog child (no fork, no heartbeat) CASEOS_WATCHDOG_SAMPLE_MS · 5000 · child sampling interval (parent RSS via ps -o rss= -p ) CASEOS_WATCHDOG_STALL_MS · 10000 · heartbeat silence that counts as a stalled host event loop CASEOS_WATCHDOG_SURGE_BYTES · 536870912 (512MiB) · RSS growth inside 30s that records host-mem-surge CASEOS_WATCHDOG_RSS_ALERT_BYTES · 2791728742 (2.6GiB) · first RSS crossing that records host-mem-surge (rss-alert) CASEOS_WATCHDOG_RESTART_MS · 2000 · base delay for re-forking the child; retry n waits n× this CASEOS_EVICT · on · 0 disables the subagent-release gate at assembly time (no agents.resume wrapper, no telemetry scan, no release_subagent tool, no events) CASEOS_EVICT_SCAN_MS · 60000 · telemetry scan interval; the scan only reports the releasable-child count, it never releases anything

Monitor events

kind values in ~/.dsh/verispring-monitor.jsonl: subagent-start / subagent-start-failed / subagent-end / subagent-error (lifecycle + role + route), child-route-updated, role-route-mismatch, scout-unreachable, scout-tools-restricted (scout child id; its edit/write were restricted on agent/created), dispatch-deny (with rule: scout-missing, brief-lint, map-baseline-missing, map-stale, executor-cap), gate-deny (dispatch spec / resident-scout / missing-map denials, plus codestyle and manon), leader-guard-deny (with rule: sleep-in-loop, sleep-long, sleep-background, list-agents-repeat), leader-write-hint (with rule: write-whitelist, git-mutation, package-mutation, in-place-edit, recursive-remove, workspace-redirect — non-blocking leader write hints), ask-scout, structure-forward, checkpoint-saved / checkpoint-save-failed / checkpoint-injected / checkpoint-inject-failed, scout-relay-error, mem-guard-boot (activation self-report: heapLimitBytes, nodeArgv, pid, softBytes, hardBytes, thresholdsSource, checkMs, readGuard, readGuardMaxBytes), mem-guard-recycle / mem-guard-skip (heap watchdog: heapUsed, released, reason), mem-guard-read-deny (whole-file read guard: tool, session, path, size), mem-guard-sample (a 5-minute heapUsed / rss / heapLimitBytes growth curve), mem-guard-soft (soft-threshold heartbeat, rate-limited to 5 minutes), mem-guard-release-hint (non-blocking leader hint after a tool result while over the soft threshold: session, heapUsed, softBytes, candidates), mem-guard-release (leader-requested subagent release: caller, child, mode: drain / dispose / noop / refused, reason, heapUsedBefore, heapUsedAfter), mem-guard-evict-boot (release gate self-report at apply time: scanMs, tracked, untracked, releaseTool, subagentProbe), mem-guard-evict-tool (final tool-registration state: tool, registered, via: root / inject, elapsedMs), mem-guard-evict-follow (follow-wrapper install: source, preexistingFollowers, elapsedMs), mem-guard-evict-scan (5-minute telemetry: tracked, candidates, resident, followerTotal, followerTracked, followerUntracked, followerProbe, subagentProbe, heapUsed), mem-guard-evict-skip (reason: scan-failed), host-watchdog-boot (child start self-report: pid, ppid, execPath, electron, sampleMs, stallMs, surgeBytes, rssAlertBytes), host-stall / host-stall-tick / host-stall-end (blocked host event loop: stalled ms, last heartbeat seq / heapUsed, RSS curve, recovery duration + peak RSS), host-mem-surge (reason: rss-surge / rss-alert, window start/end RSS, last heartbeat heapUsed), host-exit (parent died: last heartbeat age, last heapUsed / RSS, the last 10 RSS samples, newer crash-*.log names), host-watchdog-sample (5-minute child summary), host-watchdog-gave-up (3 failed re-forks).

Memory guard

The host process never evicts an opened session by itself; without a release path a long-running host eventually dies of an OOM. gates/memory-guard.js watches process.memoryUsage() from inside the host (every CASEOS_MEM_CHECK_MS, default 15s, plus immediately on subagent/end and after every tool result via tools/post-execute — one huge result can jump the heap between two samples):

  • Boot self-report — on activation it appends mem-guard-boot to ~/.dsh/verispring-monitor.jsonl

with heapLimitBytes (v8.getHeapStatistics().heap_size_limit), nodeArgv (process.execArgv), pid, softBytes / hardBytes, thresholdsSource (proportional / env) and checkMs. This is the anchor for every claim about the ceiling: read the host's real heap limit here, not the RAM size.

  • Thresholds are a fraction of the ceiling — soft = floor(heapLimitBytes × 0.6),

hard = floor(heapLimitBytes × 0.8): 2.4GiB / 3.2GiB on a 4GiB ceiling (the old fixed band), 4.8GiB / 6.4GiB on an 8GiB ceiling. CASEOS_MEM_SOFT_BYTES / CASEOS_MEM_HARD_BYTES override either side explicitly and set thresholdsSource: "env" (overriding one side leaves the other proportional). When V8 statistics are unavailable the guard falls back to a 4GiB ceiling instead of going inactive.

  • Soft threshold — observation, not action. The plugin never releases a session on its own: it

prunes its own append-only indexes of entries whose session is gone (recorded as mem-guard-recycle, 5-minute cooldown) and keeps the mem-guard-soft heartbeat. If there is nothing to prune it records mem-guard-skip (reason: "soft-threshold-no-targets").

  • Release hint — while the heap is over the soft threshold, the next tool result of a leader

session gets one non-blocking additionalContexts entry listing the finished subagents the leader may release (id, truncated description, scout or executor) and suggesting release_subagent. It is recorded as mem-guard-release-hint and shown at most once per leader session per 5 minutes; subagent sessions never get the hint.

  • Whole-file read guard — the burst is mostly one huge tool result landing in Session.log

(the host pruner used to copy results over 8KB a second time; this bundle no longer mounts it, see Context compaction), so tools/pre-execute checks the calls that dump a whole file: read without offset/limit, and bash whole-file readers (cat, less, more, most, nl, tac, strings, xxd, hexdump, od, base64, wc, sed unless it carries a -n 'a,bp' range, tail -n +N). A target larger than CASEOS_READ_GUARD_MAX_BYTES (default 2MiB) is denied, and the denial names the file size and says how to read it in segments; the hit is recorded as mem-guard-read-deny. stat failures, relative paths without a resolvable session cwd, and any parse ambiguity (redirections, command substitution, unbalanced quotes) are allowed — the guard only fires when it is certain. Root context listeners receive every agent's calls, so subagent sessions are covered as well.

  • Visibility — even below the soft threshold a mem-guard-sample lands every 5 minutes

(heapUsed / rss / heapLimitBytes), so the growth curve is visible before the cliff; a soft crossing records mem-guard-soft at the same 5-minute cadence. If a check itself hangs on a host-teardown await for longer than max(2×checkMs, 60s), it records mem-guard-skip (reason: "check-stuck") and clears checking, so later ticks are not silenced forever.

  • Hard threshold — only in the idle state (no running subagent and no tool activity for 90s) it

records mem-guard-skip and prints a restart prompt on stderr. It does not exit the host: main.js treats any host close — exit 0 included — as a failed stop and never respawns the child (see "Backend recycle" below for why the plugin cannot trigger one).

  • mem-guard-skip also records the busy case, cooldown skips and recovery failures, always with

heapUsed (plus hardBytes / released / reason where they apply).

Kill switch: CASEOS_MEM_GUARD=0 (no interval, no listeners, no boot line — the read guard is part of this gate, so it goes too; CASEOS_READ_GUARD=0 turns off only the read guard).

Host watchdog child process (out-of-process evidence)

In-process timers cannot testify while the host event loop is blocked: on the 2026-10-21 10:48 OOM the host was stuck for 45 seconds and the 15s mem-guard interval wrote nothing. gates/memory-guard.js therefore forks gates/host-watchdog-child.js at apply time — a separate node process that keeps sampling while the host is blocked:

  • IPC heartbeats every 1000ms (seq, t, heapUsed, heapTotal, rss), timer unrefed; send

failures are silent.

  • Every CASEOS_WATCHDOG_SAMPLE_MS (5000) the child reads the parent's RSS with

ps -o rss= -p (async, 2s timeout; macOS reports KB, stored as bytes).

  • Stall — no heartbeat for CASEOS_WATCHDOG_STALL_MS (10000) while the parent is alive:

host-stall once (stalled ms, last seq / heapUsed, current RSS), host-stall-tick every sample (RSS curve), and host-stall-end when heartbeats resume (duration + peak RSS).

  • Surge — RSS grows by CASEOS_WATCHDOG_SURGE_BYTES (512MiB) inside any 30s window, or RSS first

crosses CASEOS_WATCHDOG_RSS_ALERT_BYTES (2.6GiB): host-mem-surge with window start/end RSS and the latest heartbeat heapUsed; 60s throttle per kind.

  • Parent death — IPC disconnect or a failed process.kill(ppid, 0): host-exit (last heartbeat

age, last heapUsed / RSS, the last 10 RSS samples) plus the names of crash-*.log files in ~/Library/Logs/DeepSeek Harness/ newer than the child's start (names only, never read), then the child exits — no orphan ever survives the host.

  • host-watchdog-boot on start (pid, ppid, execPath, thresholds) and host-watchdog-sample every

5 minutes (RSS / peak / stall and surge counters).

The child never kills, signals or restarts the host — it only records. The parent tracks the child: if it exits unexpectedly it is re-forked at most 3 times with increasing delay (CASEOS_WATCHDOG_RESTART_MS, default 2000; 1×, 2×, 3×), after which host-watchdog-gave-up is written and restarts stop. ctx.effect teardown stops the heartbeat, disconnects IPC and kill()s the child, so plugin reloads never accumulate processes. CASEOS_HOST_WATCHDOG=0 disables it; CASEOS_MEM_GUARD=0 disables the whole memory guard including the child.

The host itself is the Desktop Electron binary running in node mode: the shell spawns it with ELECTRON_RUN_AS_NODE: "1" (/tmp/dsh-asar-extract/lib/main.js:245, :3652, :3794), and the live host confirms it (nodeArgv: ["--expose-internals"]). The fork therefore reuses process.execPath and sets ELECTRON_RUN_AS_NODE=1; mem-guard-boot now records execPath and process.versions.electron next to nodeArgv, so this can be re-verified after every restart.

Cost: one idle node child (measured: about 67MB RSS under the Electron binary in node mode, about 45MB under plain node) plus one ps call every 5s; with the watchdog off neither exists.

Subagent release, decided by the leader (session-evictor)

The host keeps every promoted session resident: the session-controller promote chain drops the dispose from the { agent, dispose } returned by ctx.agents.resume() (dsh-api-session-controller/lib/index.js:396-403), while the resume owner is the long-lived controller scope (dsh-agent/lib/index.js:466-470) — so every followed session (including subagent sessions in caseos workflows) would live until the host exits. This gate does not decide anything on its own:

  • the main/leader session is never automatically unloaded;
  • no subagent is automatically unloaded either — the leader decides, per subagent, through the

release_subagent tool (only the main session sees and may call it; subagent calls are refused);

  • the periodic scan (CASEOS_EVICT_SCAN_MS, default 60s) is pure telemetry: it reports how many

finished subagents the leader could release, and never calls dispose.

gates/session-evictor.js still wraps AgentRegistry.prototype.resume at activation: ctx.agents is a cordis traceable proxy that re-reads resume from the prototype chain on every method access (cordis/lib/index.js:84-157, :675, :686, :763-764), so session-controller's this.ctx.agents.resume passes through the wrapper. The wrapper only records the handle on the side and returns it unchanged — that captured handle is what release_subagent later disposes for subagents that were cold-resumed. Sessions promoted before activation cannot be tracked; they are reported in mem-guard-evict-boot.untracked and otherwise left alone.

release_subagent takes one parameter, agent_ids: string[], and returns one result per id: released / not-loaded / refused (with a reason), plus heapUsedBefore / heapUsedAfter. Authorization and safety:

  • the caller must be a main session (session.header.origin !== "subagent"); subagent callers are

rejected and nothing is released;

  • the target must be a descendant of the caller via the header.parentSession chain; a main

session (the caller itself included) or an unrelated session is refused;

  • the target must be finished: agent.status === "idle", durable inbox has no pending input, the

manager's ChildLock.tails has no in-flight continuation/delivery, and there is no in-flight session-controller resume/claim;

  • the target must have no live descendant (release or reject grandchildren first — this

implementation rejects, so a subtree is never torn down implicitly);

  • no UI follow stream on the target: the sessionController.history.follow wrappe