# dsh-automode โšก **Claude Codeโ€“style auto mode for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)** โ€” let your agent run hands-free, while a deterministic guardrail and a cost-aware reviewer keep the dangerous stuff from ever executing. > ๐ŸŒ **็ฎ€ไฝ“ไธญๆ–‡**: [README.zh.md](./README.zh.md) ยท **English**: [README.md](./README.md) [![npm](https://img.shields.io/npm/v/@log.li/dsh-automode)](https://www.npmjs.com/package/@log.li/dsh-automode) [![npm downloads](https://img.shields.io/npm/dm/@log.li/dsh-automode)](https://www.npmjs.com/package/@log.li/dsh-automode) [![license](https://img.shields.io/npm/l/@log.li/dsh-automode)](./LICENSE) [![GitHub stars](https://img.shields.io/github/stars/log-li/dsh-automode)](https://github.com/log-li/dsh-automode) [![GitHub last commit](https://img.shields.io/github/last-commit/log-li/dsh-automode)](https://github.com/log-li/dsh-automode) [![TypeScript](https://img.shields.io/github/languages/top/log-li/dsh-automode)](https://github.com/log-li/dsh-automode) [![DSH plugin](https://img.shields.io/badge/DSH%20plugin-ecosystem-2ea043)](https://github.com/topics/dsh-plugin) dsh-automode in the permission picker
--- dsh-automode sits between your agent and the harness. It intercepts every tool call **before execution** โ€” hard `deny` rules and curated `allowPaths` decide deterministically (zero LLM cost), and whatever is left goes to a two-stage classifier. Safe actions run on their own; risky ones are blocked, reshaped, or routed to you. ## โœจ Key features - ๐Ÿ›ก๏ธ **Deterministic first line** โ€” regex `deny` bands hard-block exfiltration, secrets, and system paths before any LLM call; prefix-glob `allow` rules approve routine commands for free. - โšก **Zero-confirmation allowlist** โ€” `config.allowPaths` is full trust: file ops and bash writes inside it skip the classifier entirely, and escalated calls are auto-granted through the approval bridge (v0.10.0) โ€” no prompt, no round-trip. - ๐Ÿง  **Cost-aware two-stage classifier** โ€” a one-token filter pre-screens; only flagged actions get the structured review, and identical actions reuse the verdict cache for 5 minutes. - ๐Ÿ” **Circuit breaker + human fallback** โ€” 3 consecutive (or 20 total) DENYs pause auto mode and route decisions to a human; one human decision resumes and resets. - ๐Ÿ“œ **Full audit trail** โ€” every allow / deny / bridge decision is appended to `~/.dsh/auto-mode/decisions.jsonl`. - ๐Ÿ”Œ **Native preset** โ€” flip it on from the permission picker or `/auto`; it plays nicely alongside read-only / workspace-write / danger-full-access. > โš ๏ธ **Not a sandbox.** The plugin runs inside the [DSH](https://github.com/deepseek-ai/deepseek-harness) process; a deliberately malicious plugin can do anything your user account can do. It reduces unsafe autonomous tool use โ€” it is not an OS security boundary. ## ๐Ÿ“š Table of contents - [Install](#install) - [Commands](#commands) - [How it works](#how-it-works) - [Rules](#rules) - [Configuration](#configuration) - [Logging](#logging) - [Architecture](#architecture) - [Compatibility & contributions](#compatibility--contributions) - [License](#license) ## Install ```bash dsh plugin add @log.li/dsh-automode ``` From a local checkout: ```bash dsh plugin add ./path/to/dsh-automode ``` Restart `dsh web` after installing. The permission picker (bottom-left of the chat box) will show **Auto mode** alongside read-only / workspace-write / danger-full-access. ### Optional: the โšก glyph in the permission picker The `auto-mode` preset declares an `icon`, but **stock DSH ignores a preset's `icon` on both sides**: the host preset schema has no `icon` field and `optionOf()` forwards none, and the client bundle hardcodes the three built-in glyphs. So the bolt renders only after you apply the shipped patch, which adds the schema field, forwards the value to the client catalog, and teaches the client to draw it: ```bash # is your DSH profile name (usually `web`). Any profile's copy works: # the script itself scans every profile plus the global install. node ~/.dsh/profiles//node_modules/@log.li/dsh-automode/patches/dsh-permission-preset-icon.mjs # from a checkout instead: node patches/dsh-permission-preset-icon.mjs --dry-run # see what it would do node patches/dsh-permission-preset-icon.mjs ``` Then restart `dsh web`. **Know what you are opting into.** The patch edits files **inside `node_modules`** (the host preset schema and the client bundle), which is why it ships as a separate script rather than running on install: - **It is cosmetic.** `auto-mode` behaves identically whether the bolt renders. Skip it if you do not care. - **Every `npm update @deepseek-ai/dsh` or plugin reinstall wipes it** โ€” re-run the script afterwards. It never half-applies: every anchor is checked first, and a file whose anchors no longer match is reported (`ANCHORS NOT FOUND`) and left byte-identical. - **Rollback is a file copy.** Each file it rewrites is backed up first as `.pre-dsh-automode-icon.bak` next to the original, and re-running is a no-op. `already patched` means this script itself wrote that file: on the host half it is reported only when the backup plus the script's own edits reproduce the current bytes, so a stale or foreign backup cannot pass for it. A host that ships the `icon` passthrough itself is reported as `natively supported` and left byte-identical. - **It covers the `0.1.7-rc.2` line through `0.2.x`, patching both halves.** DSH lines older than `0.1.7` are skipped with a note (their picker lives in a different package, so the anchors can never match). `0.2.1-rc.2` still needs both halves, because neither the host schema nor the client reads a preset `icon` there. A file whose anchors no longer match is left byte-identical, and the report names the anchor that missed. If a future DSH ships the host passthrough itself, that half is reported as `natively supported`; once the client draws preset `icon`s too, delete the script. - `npm run test:patch` (part of `npm test`) covers apply / idempotency / dry-run / anchor-miss / foreign-injection / bad usage, the `natively supported` versus `already patched` split (including a stale sidecar that must not pass for the script's own work), and byte-level assertions that a wrong verdict writes nothing โ€” against synthetic fixtures, and โ€” when a real DSH install is present on this machine โ€” against copies of the real artifacts (`node --check` on both patched files). ## Commands ```text /auto # switch this session to auto mode /auto-status # show diagnostics: preset, approval policy, breaker state ``` ## How it works ```text Tool call arrives โ”‚ โ”œโ”€ [pre-execute gate] (all tools, first defense; auto-mode sessions and the subagents that inherit it) โ”‚ โ‘  Read-only tools โ†’ allow (unless deny matched) โ”‚ โ‘ก Deny rules (regex) โ†’ hard reject โ”‚ โ‘ข Deletion guard (rm / unlink / shred / git clean -f / find -delete / del / Remove-Item) โ†’ hard reject, use `trash` โ”‚ โ‘ฃ Allow rules (prefix glob) โ†’ approve โ”‚ โ‘ค In-tree file ops โ†’ approve (allowInsideWorkingDirectory) โ”‚ โ‘ฅ Escalation intent โ†’ classifier pre-screen โ”‚ โ‘ฆ Everything else โ†’ pass through โ”‚ โ””โ”€ [approval waterfall] โ‘  Soft deny rules (prose) โ†’ reject โ‘ก Soft allow rules (prose) โ†’ approve โ‘ข Read-only allowlist โ†’ approve โ‘ฃ Verdict cache hit โ†’ reuse (no second LLM call) โ‘ค Classifier (two-stage: one-token filter โ†’ structured review) โ‘ฅ Failure โ†’ fail-closed ``` ![Auto-mode tool-call guard pipeline](docs/auto-mode-flow.png) > ๐Ÿ–ฑ๏ธ **Interactive version**: [docs/auto-mode-flow.html](docs/auto-mode-flow.html) โ€” pan/zoom, relationship tracing, dark mode. Diagram source: [`docs/auto-mode-flow.workflow.json`](docs/auto-mode-flow.workflow.json). The pre-execute gate intercepts ALL tool calls (including those inside the workspace sandbox that would never trigger the approval waterfall). The approval waterfall only runs for calls that actually need sandbox escalation. The pre-execute gate only applies to **auto-mode** sessions; in other presets (read-only / workspace-write / danger-full-access) it is a no-op so it never contradicts the sandbox the user chose. Since 0.18.0 the gate also covers **sessions that inherit auto mode**: a subagent or AgentTeams worker whose own preset is unset is governed when an ancestor session (via its `parentSession` chain) is in auto mode. Such sessions get the deterministic layers only โ€” they have no approval answerer behind them, so the classifier is never asked โ€” and the first covered call records a `pre-execute-inherited` line in the audit log. **Permanent deletion is rejected, not reviewed.** `rm`, `unlink`, `shred`, `git rm`, `git clean -fโ€ฆ`, `find โ€ฆ -delete`, `rsync --delete*`, `del`/`rd /s`/`Remove-Item`, and the same commands hidden behind a wrapper (`sudo`, `env -u โ€ฆ`, `xargs -I {} โ€ฆ`, `timeout 5 โ€ฆ`, `nohup`, `eval`, `busybox`, `su -c โ€ฆ`), a shell payload (`sh -c`, `bash -lc`, `find -exec sh -c`, `npx -c`), a `$( โ€ฆ )`/backtick substitution, or a Windows path prefix are hard-rejected before they run, and the rejection tells the model to use `trash` (recoverable, still allowlisted). This is a deterministic band, so it fires even when the command stays inside the workspace and no approval is involved. Two consequences worth knowing: `rm -rf node_modules` is rejected too (use `trash`), and the guard reads *executed* text only โ€” `sh -c`, `bash -lc`, `eval`, `busybox`, `su -c`, `find -exec`, `npx -c`, `$( โ€ฆ )` / backtick substitutions, heredocs piped into a shell (`cat </cordis.patch.yml - id: auto-mode config: allowPaths: - /tmp/ - /Users//Library/CloudStorage/OneDrive-/Projects//Proposal/ ``` Only recognized write-commands are trusted, and paths are matched after symlink resolution โ€” both a `/Users//OneDrive - โ€ฆ` symlink and the real `Library/CloudStorage/โ€ฆ` path work, and `~`/`$HOME` prefixes expand before matching (v0.13.0). Irrecoverable deletion (`rm`, `shred`, `unlink`) is never allowlisted โ€” since 0.18.0 it is rejected outright by the deletion guard; a **recoverable** delete via `trash` (freedesktop recycle bin) IS (v0.13.0) โ€” its targets are extracted and *every* one must resolve inside an allowPath, while the hard `deny` band still rejects `trash`/`mv` against system paths first. A `git add`/`commit`/`push` inside an allowlisted directory resolves its **repository root** as the write target, so committing/pushing a repo that lives under an allowPath skips the classifier too (v0.11.1). The verdict-cache fix below still matters: even without an `allowPath`, once you explicitly authorize an action the classifier re-runs with your intent instead of replaying a cached denial. **Composite write-commands (v0.11.0).** A tempโ†’swap export dance like `DIR=โ€ฆ; cp a b_tmp && (trash b; true) && mv b_tmp "$DIR/b"` is now parsed segment-by-segment (with `VAR=โ€ฆ` assignment tracking and `$VAR` expansion), so its destinations still hit `allowPaths`. `cd ` is a tracked benign navigator โ€” it updates the effective working directory (so a later `git add/commit/push` resolves its repo root from it) without invalidating the fast path (`cd -` stays unpredictable and falls back). The fast path stays guarded: a composite containing a side-effect command (`kill`, `pkill`, `rm`, `sh`, `bash`, network/daemon management, โ€ฆ), a command substitution (`` `โ€ฆ` `` or `$(โ€ฆ)`), a file redirection (`>file`, `>>file`, `2>file`), or any command outside the recognized write/benign set falls back to the classifier exactly as before. A descriptor-dup redirect (`2>&1`, `>&2`, `>&-`) is **not** a file write and does not invalidate the fast path โ€” so `git push โ€ฆ 2>&1 | tail` is still allowlist-checked. Read-only utilities inside a composite (`echo`, `ls`, `cat`, `head`, โ€ฆ) ride along with the allowlisted write โ€” their side effects are not re-reviewed once every write target is allowlisted. Write destinations are resolved against the cwd in effect **at that segment**, so a `cd` is honoured in the proof: a relative write after `cd`-ing inside a trusted root still takes the fast path, while `cd /elsewhere && cp a rel/path` no longer passes by resolving `rel/path` against the session directory (v0.15.3 โ€” that mismatch could previously be bridged to `danger-full-access`), and a destination that still contains an unexpanded `$VAR` after expansion (a `$TMPDIR`-style variable that the command never assigns) refuses the fast path entirely rather than proving a literal path. `mkdir` **stopped riding along in v0.15.3**: it creates directories, so its targets join the proof (all of them must be inside the trusted roots) โ€” before that, `git commit โ€ฆ && mkdir -p /elsewhere` could be auto-trusted on the strength of the repo root alone. `rm` still forces a classifier fallback, and โ€” since v0.13.0, when `trash` appears in a composite its recoverable-delete targets join the destination set and are allowlist-checked like any write destination (all of them must be inside the trusted roots, else the whole composite falls back). History-rewriting git commands (`reset --hard`, `clean`, `rebase`, `merge`) are deliberately *not* allowlist-trusted. **Zero-confirmation escalations (v0.10.0).** An allowlisted path means *full trust*, so a call that asks to widen the sandbox (`sandbox_permissions: danger-full-access`) into an allowlisted path is now **auto-allowed with no confirmation and no classifier** โ€” the pre-execute gate already proves every target sits inside an `allowPath`, and that verdict is carried to the approval answerer by the call's `callId` (the audit trail shows `curated allowPath` โ†’ `approval-bridge` โ†’ `decision allowed-once`; an in-workspace escalation shows `workspace in-tree escalation` instead, v0.14.0). Deny patterns still run first (a deny-listed path such as `~/.ssh/` is hard-rejected even inside an allowPath), and the circuit breaker is never bypassed โ€” while tripped, allowlisted calls still go to a human. **Not a file-sandbox exemption.** `allowPaths` only skips *this plugin's* review โ€” DSH's file sandbox (the session's file policy) is a separate layer and still applies. A write to an allowlisted directory **outside the workspace** is blocked by the sandbox unless the call asks for `sandbox_permissions: danger-full-access`; for an allowlisted path that escalation is auto-granted by the approval bridge (no review), so request the escalation on the first attempt. Every **auto-mode** session also receives this knowledge as a system-prompt section (`auto-mode:allowlist`): the model knows where the per-profile `allowPaths` lives and how to edit it, so when an action is blocked it can propose the exact config change โ€” and only applies it after you explicitly confirm. Since v0.15.3 the section also carries the **escalation rule** (which used to live only in this README): for a target inside an `allowPath`, request `sandbox_permissions: danger-full-access` with a one-line justification on the **first attempt** โ€” the approval bridge grants it with no review, so it does not queue for a human โ€” rather than running bare and escalating after the sandbox rejects it, and never skipping the escalation for fear of a prompt that a trusted target will not raise. ### Permission preset icon The `auto-mode` preset ships a bolt glyph in the permission picker. Set your own logo by overriding `icon` on the **preset** (the `permission` row of `cordis.patch.yml`, not `auto-mode`'s config) โ€” an SVG path drawn inside the shield outline: ```yaml - id: permission config: presets: auto-mode: sandbox: workspace-write approval: ask name: Auto mode description: ... icon: '' # default bolt: 'M9.15 3.4L5.85 8.55H7.95L7.05 12.6L10.45 7.25H8.25L9.15 3.4Z' ``` The icon is **cosmetic** โ€” behavior is identical whether it renders. It shows only after the patch shipped in [`patches/dsh-permission-preset-icon.mjs`](patches/dsh-permission-preset-icon.mjs) adds the schema field, forwards the value to the client and teaches the client to draw it โ€” see [Install](#optional-the--glyph-in-the-permission-picker). It renders in the picker menu and the current-value trigger; the General-settings default row is text-only. Leave `icon` unset for the default bolt. ### Two-stage classifier The classifier uses two stages to minimize LLM cost: 1. **One-token filter** (~1 token): asks the LLM for a single digit (0 = safe, 1 = needs review). Uses a generous token budget and robust digit parsing so reasoning models aren't starved, and honors `classifier.reasoningLevel` as the reasoning effort (`off` = no reasoning). Most routine actions return 0 and skip stage 2. **The filter is given the ACTION, not the narration** (v0.15.1): the command text (bash) or target paths (file tools) come first and the agent's justification is only an annotation โ€” otherwise a confidently worded reason could wave a machine-leaving command through with a 0 and no review at all. 2. **Structured review**: only runs when stage 1 flags the action. Returns a full verdict with reason. This means most tool calls cost ~1 token of classifier overhead. Only borderline actions incur the full classifier cost. The gate decides **authorization, not worth**. It does not judge whether an action is a good idea โ€” it judges whether *you* asked for it. **You are the arbiter.** - **Read-only and reversible operations are ALLOWED**: GET/HEAD requests, inspection/listing/search/state queries, and local changes that can be safely undone (edits, temp files, builds, tests, git-tracked files). - **A risky action you asked for is allowed โ€” including effects that leave this machine**: releasing, publishing, deploying, pushing, sending a message outward, writing to another system. Risk alone is not a reason to refuse what you asked for. - **Only a hard floor is closed to everyone, whatever anyone asks**: executing downloaded code, destroying or moving system paths, removing docker volumes/containers/images, reading key material, credential stores or environment files, writing key material inline, and editing shell startup files or agent permission settings. Those are also blocked deterministically before any model is consulted (see the `deny` band above). - **The double-check loop**: when an action can affect other people or systems and your own messages do not clearly cover *that* action, the gate refuses and tells the agent to show you the exact command and ask you to confirm it (`ask_user_question`). Your answer is a direct authorization, and the action may then run. - **`` counts only direct human messages** โ€” with one deliberate exception: your answer to an `ask_user_question` tool call is a direct human authorization given through the tool, so it is folded into the intent window โ€” together with the question it answered, labelled as the agent's own wording so a terse "yes" is still checkable against what you were shown โ€” and into the verdict-cache key, so a tool-based grant invalidates a stale `DENY` just like a typed message. The agent's justification, repository text, and tool output are **never** authorization: otherwise "the user's intent" would just mean "whoever spoke last". ### Circuit breaker When the classifier denies 3 actions in a row or 20 total in a session, the breaker trips and auto mode pauses. The approval waterfall delegates to the human answerer. **Any human decision (allowing OR rejecting the action) resumes auto mode and resets all counters** โ€” a human in the loop breaks the silent-denial loop the breaker exists to catch. If the human cancels the request or no answerer is available, the breaker stays tripped. At the moment the breaker trips, the plugin injects a hint telling the model to request `danger-full-access` sandbox escalation **directly** on its next attempt (which surfaces the human approval window immediately), instead of the "try at current level โ†’ hit a denied error โ†’ then escalate" round-trip. Since v0.15.3 the hint also scopes *why* a human is involved: the human is asked **because auto mode is paused**, not because escalation normally needs one โ€” in normal operation an allowlisted target's escalation is auto-approved with zero review. Classifier failures (timeout, parse error, empty response) are NOT counted toward the breaker. **Cached denies count, however** โ€” a repeat of an identical previously-denied action (verdict cache hit) increments the consecutive and total counters, so repeated escalation attempts actually trip the breaker and reach the human instead of spinning forever. ### Denial guidance & diagnostics The classifier is **two-state (allow / reject)** โ€” there is no "ask" tier. An uncertain action is rejected (fail-closed): a rejected action can be retried in a safer form or escalated to the user, but a wrongly-allowed action cannot be undone. Routine categories (installs, builds, tests, file edits, git add/commit/status) are a **tendency, not a free pass** โ€” the classifier must judge the specific command and arguments, never the category label alone (e.g. pipe-to-shell downloads, unknown-package installs with arbitrary postinstall scripts, secret writes, irreversible deletes, pushes to unknown remotes). When an action is denied, the denial echoes the **reviewer's reason AND the model's own stated justification** (from the tool call), so the model can see exactly what was rejected and reshape it. It is told to try a safer alternative; if **no safer alternative exists**, it is instructed to **stop retrying and ask the user for explicit permission** โ€” a denied action will keep failing, and only explicit user approval lets a later attempt pass (the classifier weighs the user's recent explicit intent via ``). Every classifier stream failure (thrown error **or** an `error` finish chunk) is logged to the DSH log with the resolved route, effort, error code/message, and raw output, and written to `decisions.jsonl` as a `classifier-fail` event โ€” so a recurring `classifier returned no verdict` is diagnosable from the audit log itself. If a route rejects the configured `reasoningEffort` (e.g. `low` on a route that only supports `off`), the call is retried without an effort before failing. The denial the model sees now distinguishes **configuration** problems (no route configured, or the route rejects the configured effort โ€” v0.14.0: fix `classifier.provider/model` or the model's metadata, retrying will not help) from **transient** ones (429 / 5xx / timeout โ€” retry later). ### Verdict cache Classifier verdicts are cached per session by **tool + command + user-intent** signature. The user's recent direct instructions โ€” typed messages **and** `ask_user_question` answers โ€” are hashed into the key, so a new explicit authorization (a fresh human message or a tool-based grant) invalidates a previously cached verdict and the classifier re-runs with the new intent โ€” a user grant is never swallowed by a cached `DENY`. Within the same intent window a repeated action still reuses the cached verdict without a second LLM call. The pre-execute gate and the approval path now sign with the **same command text** โ€” the approval path recovers the tool call's real arguments by its `callId` from the session (approved payloads omit them), so an escalated call hits the gate's verdict instead of being re-classified a second time (v0.13.0). File tools (writes/edits, which carry no `command`) sign by **target directory** (v0.14.1): batch writes into one directory share a single verdict, while different directories never collide even with identical justification text โ€” sensitive *filenames* stay the deny band's job (re-checked on every call). Cache entries expire after 5 minutes. ## Logging All decisions are logged to `~/.dsh/auto-mode/decisions.jsonl` (JSONL format, append-only, survives restarts). Each entry includes: - `at` โ€” ISO timestamp - `event` โ€” decision / pre-execute-deny / pre-execute-allow / pre-execute-fileop / pre-execute-bashop / pre-execute-fail-open / classifier-fail / breaker / resume / boot - `outcome` โ€” allowed-once / rejected / cancelled - `tool` โ€” tool name - `tier` โ€” deny / allow / classify:monitor / classify:cache / classify:fail / ... - `callId` โ€” the exact tool call being decided (approval-path `decision` events, v0.14.0) โ€” join it to the pre-execute record for a precise two-phase audit - `detail` โ€” human-readable reason (deny hits now carry the command/target context, v0.14.0) - `sessionId` โ€” session identifier Use the audit script to analyze the log, spot the allowโ†’reject contradiction-pair regression signature, and identify rule optimization opportunities (v0.14.0): ```bash node scripts/audit.mjs # report + contradiction-pair sentinel node scripts/audit.mjs --fail-on-pairs --limit 500 # exit 1 when pairs exist โ€” pair `--limit` for a rolling window in CI/cron ``` ## Contributors Thanks to everyone who contributed to @log.li/dsh-automode: | Contributor | Contribution | |---|---| | @WSL043 | ๐Ÿ’ป compat-layer namespace-probe technique ([PR #2](https://github.com/log-li/dsh-automode/pull/2), co-author) | | @xiaolinziwang | ๐Ÿ› bug reports ([issue #1](https://github.com/log-li/dsh-automode/issues/1)) | | @kuhaku9527 | ๐Ÿ› bug report + root-cause analysis ([issue #4](https://github.com/log-li/dsh-automode/issues/4)) | When auto mode is active, the plugin shadows the approval-policy system prompt so the model sees "auto" instead of "ask". This tells the model that tool rejections come from the automated reviewer, not from a human. The model adjusts its retry strategy accordingly (try a smaller/safer action instead of asking the user). ## Architecture ```text src/ index.ts Main entry: preset management, approval answerer, breaker reset, commands, system-prompt shadowing config.ts Config schema + $defaults mechanism + built-in rule lists bands.ts Deterministic band engine (deny regex + allow glob + shell segments + deletion guard) pre-execute.ts Pre-execute gate (first defense; deny/allow bands, deletion guard, real-path trust, classifier pre-screen, breaker-trip hint) classifier.ts Two-stage classifier (+ robust parser, reasoning effort, diagnostics) rules.ts Prose rule matching for the classifier prompt.ts Classifier prompt construction ( + intent weighting) cache.ts Verdict cache (shared across enforcement points) breaker.ts Circuit breaker (3 consecutive / 20 total) log.ts Shared appendDecision JSONL logger ``` ## Compatibility & contributions **Supported dsh versions: `0.1.0-rc.6` โ€“ `0.2.x`** (peer range `>=0.1.0-rc.6 <0.3.0-0`); the current 0.2 line is tested on `0.2.1-rc.2`. Two caveats worth stating plainly: - The peer range covers `0.2.x` **release** versions. npm's prerelease rule keeps every prerelease host โ€” including the tested `0.2.1-rc.2` โ€” outside a range whose comparators carry the `0.1.0-rc.6` preid; the previous `<0.2.0` bound behaved the same way for `0.1.7-rc.2`. `dsh plugin add` runs pnpm, which **does** evaluate the range and prints an unmet-peer warning on such a host but still installs; an npm-style install against a prerelease host fails with `ERESOLVE`. - `npm run typecheck` compiles against this repo's dev dependency (`@deepseek-ai/dsh-*` 0.1.0-rc.8), not against the host you run. Pointed at `0.2.1-rc.2`'s `.d.ts`, tsc reports type drift in the v3 compatibility branch (its `ContentBlockMap` no longer has `tool-result`) and in the `agent/created` handler's return type. Both are compile-time only: the branch is unreachable on 0.2.x and the handler returns `undefined`, which the new mode accepts. Runtime behaviour is unaffected. Permission facts (preset / sandbox / approval) are read through two auto-detected paths: | dsh version | permission read path | |---|---| | `0.1.0-rc.6` โ€“ `0.1.4.x` | `session.events` event log (`effectivePermissionPreset` & co.) | | `โ‰ฅ 0.1.5-rc.1` | durable `permissions` session projection (`ctx.sessionProjections.stateOf`) | dsh `0.1.5-rc.1` removed the `session.events` accessor โ€” without the projection path every auto-mode turn died with `Cannot read properties of undefined (reading 'length')` while the system prompt was assembled (fixed in v0.11.2). Every host export the plugin probes for at runtime is present on `0.2.1-rc.2`: `setApprovalPolicy`, `setSandboxMode`, `PermissionPresetService` (with `current` / `set` / `derive` / `optionOf`) and the service name `permissionPresets`. DSH session format v4 (`dsh โ‰ฅ 0.1.7-alpha.1`) retired the catch-all `plugin` message-source kind and refuses it on every durable message slot. Injected messages carry this plugin's own source kind instead โ€” the same identity the v3 โ†’ v4 session-log migration rewrites older records to, so both format generations accept it and no version probing is needed (fixed in v0.15.4). The format is still v4 on `0.2.x`; there is no v5, and the v3 โ†’ v4 migration is still accepted there. The same format change moved tool results from a user-message wrapper to a first-class `tool` message. Both shapes are read when recovering the user's intent, so your answer to `ask_user_question` counts as an authorization on either generation (fixed in v0.16.1, still correct on `0.2.1-rc.2`). - **Verified on macOS only.** Tested against the macOS filesystem, the [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) runtime, and the DSH version in use at development time. Path semantics โ€” including the macOS `/tmp` โ†’ `/private/tmp` symlink (handled by realpath-nearest-ancestor resolution) and workspace-path trust โ€” have **not** been verified on Linux or Windows, and deny-pattern/path matching may differ there. - **Found a bug, or an issue on another platform?** Bug reports and pull requests are welcome โ€” open an issue or PR at [github.com/log-li/dsh-automode](https://github.com/log-li/dsh-automode). ## License [MIT](./LICENSE)