English | 简体中文
A DeepSeek-powered terminal coding agent, written in Rust. One small binary: streaming TUI, OS-level sandboxing, role-based sub-agents, and a safety-first execution policy.
Small, fast, self-contained
- Single native binary (~4–6 MB) per platform — no Node/Python runtime needed at run time. Prebuilt for macOS (arm64/x64), Linux (x64/arm64, glibc ≥ 2.35), and Windows (x64).
npm i -gfetches the right binary and verifies its SHA-256. musl hosts (Alpine and most slim images) are not supported yet — the installer detects and refuses them with a clear error instead of installing a broken binary.
Safety-first execution
- Four permission tiers —
default/accept_edits/auto/yolo, cycled with Shift+Tab.autogrants everythingaccept_editsdoes and delegates the rest to a cheap Flash classifier, behind hard floors it can never override: a network request never reaches the classifier — it asks a human unless a standing consent (auto_allow, session "a") already covers the call;[sandbox] network = "always"gives sandboxed commands ambient egress with no declaration and no prompt, but a call that does declarenetwork: true, or a network-native tool, still asks — a write-root request is never auto-approved, and the top risk tier never reaches the classifier — it asks unless theaccept_editsallowance already covers it (a plainmkdir src/xruns unasked here exactly as it does there).yoloauto-approves everything the gate sees except a write-root request and an unauthorized irreversible command (both ask in every tier) and gives sandboxed commands ambient egress — the per-call network prompt exists so a human sees egress requests, and with nobody in the loop it would stop no attacker (a hostile call just declaresnetwork: trueand gets auto-approved) while stranding honest undeclared commands offline.[sandbox] network = "never"stays absolute even under yolo — it refuses declared commands, networked dispatches and the web tools (fetch_url,web_search) alike; write confinement and the deny floor are unaffected. One more floor yolo does not lift: irreversible outward commands (publishing a package, force-pushing, merging a PR, applying infrastructure, deleting a bucket) are never auto-approved by anything — not yolo, not a configauto_allowentry, not the judge, not session memory. They ask a human in every interactive tier (approve once, or say no), are auto-denied where nobody can answer (a headless run, a sub-agent), and stop asking for good once you authorize that class with[sandbox] allow_irreversible. - OS sandbox (macOS / Linux) — shell/job commands run inside macOS Seatbelt or Linux Landlock+seccomp: writes confined to the workspace, any root you granted (
--add-dir,/add-dir, an approvedrequest_write_root), the command's own cwd and the system temp dirs, no network by default. Commands that need egress (installs,git push, dev servers) must declare it and go through approval.[sandbox] network = prompt|always|nevertunes this, and project-level config can only tighten it. If no sandbox backend is available, commands are refused rather than silently run bare.[sandbox] mode = "off"is the one deliberate exception on the other side of that: for a host where the boundary is already provided outside this process (a container, a micro-VM, a disposable CI runner), it runs commands bare instead of refusing, is settable only from the global layer, is reported bydeepcode doctor, carries a permanent[unsandboxed]marker in the status line, and is refused outright by the eval harness. On Linux, how completely writes are confined depends on your kernel: Landlock gained the right governingtruncate(2)in ABI 3 (Linux 6.2) and the one governing deviceioctl(2)in ABI 5 (Linux 6.10), and a right the kernel cannot express is one it never checks. The two gaps are not the same gap, so they are not reported as one. Below 6.2 (Ubuntu 22.04, Debian 12, RHEL 9) every other write outside the roots is still refused — create, delete, open-for-write — so the residual is destructive (a file outside the roots can be emptied), never disclosing. Below 6.10 (Ubuntu 24.04 and most current distros) the path-write boundary is fully intact; what goes unchecked isioctlon a device node, so the reach is bounded by which devices your user can open rather than by which paths were granted — and the/devnodes this sandbox grants for redirection are granted without the ioctl right (on kernels that enforce it, this deliberately breaks pty allocation inside the sandbox — expect/pexpect-style tools — and the model is told that refusal is by design, not a path denial to fix with/add-dir).deepcode doctornames the exact gaps, the approval panel says "partial sandbox" rather than "sandboxed execution", and the model's tool description carries the sentence for the gap you actually have — it is the one issuing the write, so it is the last place a gap may be rounded, in either direction. The network guarantee is unaffected: seccomp denies thesocket/connectsyscalls outright, with no per-kernel right to negotiate;io_uringis denied under every policy (a syscall filter never sees ring submissions, so leaving it reachable would have made that denial advisory); and the syscalls that hand over another process's memory or an already-open socket —process_vm_readv,pidfd_getfdand friends — are denied alongsideptrace. Unprivileged user namespaces are closed in all three spellings —unshare/setnsrefused outright,clone(CLONE_NEWUSER)refused by argument filter,clone3answered with ENOSYS so every libc falls back to the filterableclone(2); the practical cost is that browser sandboxes launched inside the agent sandbox (Puppeteer/Playwright headless Chromium) need--no-sandbox, the same flag they already need on distros that restrict unprivileged userns. Windows, please read: only Job Object process-tree containment exists there — file writes and network are not restricted, and thenetworksetting is a no-op. The deny floor and the approval gate still apply, but "out-of-workspace writes get blocked for you" does not hold on Windows.deepcode doctorreports exactly what is enforced on your machine. - Deny floor — catastrophic commands are hard-refused at any tier and cannot be allow-listed:
rm -rfon system roots, disk formatting (format C:, volume-GUID/device-path/\\?\spellings,diskpart), registry deletion, and friends — including their Windows spellings. - Model-requested write grants — when a task genuinely needs a directory outside the granted roots, the model can call
request_write_rootwith a stated reason; the approval panel shows the resolved directory and that reason (labelled as the model's claim), and you decide — a symlink spelling is resolved before you judge, the grant is re-checked against that exact resolution at approval time (a path swapped under the prompt is refused), and requests that cover the home directory or filesystem root, or that overlap a credential store (~/.ssh, the AWS/GCP/Azure trio,~/.gnupg,~/Library/Keychains, other agents' token stores, … and deep-code's own~/.deep-code, which holds the API key), bounce before you are even asked. That refusal is in-process, so it holds on every platform — and it is the floor's own work, not the sandbox's: on macOS the Seatbelt profile denies writes to the same list (by resolved path, so a symlinked~/.awsis covered), but on Linux this floor is the only one of the two, because Landlock is allow-list-only and cannot express a deny inside a granted root, and on Windows there is no filesystem confinement at all. One limit worth knowing: the floor refuses to widen the boundary onto those paths — it does not narrow anything, so a session started inside your home directory already has them writable. Approval widens the boundary live — no relaunch — and the grant persists with the session like an--add-dir, re-checked against this same floor on-c(the session file lives inside the workspace, so it is itself something the model can write). Denial grants nothing and tells the model not to ask again, though it can; what is guaranteed is that no channel auto-approves: notyolo, not theautoclassifier, not config auto-allow, not session memory.Enteron that panel defaults to deny. - Trust that doesn't leak — "always allow" for shell matches on command identity (program + subcommand): trusting
git pushdoes not wave throughgit status. Flags that change what executes or where it writes (--config,--exec-path,--output,--target-dir, …) break the trust match and re-prompt, and so does any operand that names a path outside the working directory by spelling (git diff /dev/null ~/.ssh/id_rsaasks;git diff main..HEADdoes not) — the sandbox leaves reads open, so that spelling is the read fence. Shell metacharacters ($, backticks, redirection, brace expansion, glob patterns, subshells) route to approval instead of being parsed optimistically — the gate compares written text against its rules, and anything the shell would rewrite first is excluded rather than guessed at; which characters those are is checked against the real shell by a test, not remembered. Commands that run without a prompt — a trusted identity, a remembered session consent, an accept-edits file operation — are executed as the exact words the gate parsed, with no shell in between (&∧are sequenced by deep-code itself); only a command a human approved as text gets shell semantics, because that text is what the human read.
Sub-agents with real guardrails
- Delegate investigation or implementation to child agents via one blocking
agenttool call; issue several calls in one turn to run children in parallel. Six roles —general/explore/plan/review/verifierare strictly read-only; onlyimplementercan write, and dispatching one is itself an approval point: the human authorizes the dispatch, the child's workspace writes then proceed unattended (on the tiers where writes would prompt). Network is dispatch-scoped the same way: children have no network at all unless dispatched withnetwork: true, which is its own approval point (the prompt says what it means — anything the child reads may be sent to external hosts). A granted child getsfetch_url/web_searchand egress for its allow-listed commands; the command allow-list itself never widens. A child also inherits this session's permission mode, which keeps the session's policy readings (egress, the sandbox switch) coherent — but inheritance is not capability. A child's own gated calls are decided by the unattended sub-agent policy, which never consults the mode, so a shell command outside the trusted list (git push,npm run e2e) is denied in every mode,yoloincluded. There are exactly two ways out, and both are decisions you make at dispatch time:allow_commandsnames command identities the child may run (["git push"]raises its own approval prompt naming them), andnetwork: truegrants egress. Without one of those the denial tells the child to report the need, and the parent — which does have the human, or the mode — runs it. A child still cannot be helped into an irreversible command:allow_commandscannot lift that floor either, so the parent runs those. Denied prompts inside a child state the real reason — auto-denied by policy, no user saw it — and name the recovery (work within the grant, or report back for a re-dispatch), instead of pretending a user refused. A child that runs out of its tool-call budget hands its partial report back with the failure, so the parent can continue from it rather than re-dispatching from zero. A cancelled child reports only that it was cancelled — the cancel is the user's own decision, and the parent is by definition still there to decide what happens next. - Reconnaissance roles (
explore/review/verifier) are pinned to the cheap flash tier — fan-out burns tokens where it's cheapest — while children stream live progress into the parent transcript:[explore] +41s step 7/50: grep_files, so a long-running child never looks hung. - Child token spend is folded into the parent session's cost tracking.
Sessions you can trust
- Persistence +
-cresume +-rpicker; per-turn checkpoints with/restorerollback, snapshotted via copy-on-write clones where the filesystem supports it (APFS / Btrfs / XFS). - Automatic context compaction with a bounded summary carry; costs are tracked per request (including cache hit/miss savings and sub-agent spend), shown in
/status, in your currency of choice. - Shell output past the inline window is saved to a file in full (64 MB per-stream cap, head kept) and the result names its path — the head of a long build log (where the first error lives) stays retrievable instead of vanishing. Files live under the workspace's
.deep-code/spill/, are skipped by checkpoints and default code search, small outputs never touch disk, and runs idle for a week are pruned at the next launch.
A TUI that stays honest
- Streaming responses with DeepSeek reasoning, mouse scroll/select, paste folding, completion menus. Code blocks are syntax-highlighted (75 bundled grammars, 24-bit or 256-color to match the terminal), and pipe tables lay out as real columns — CJK-aware widths, cells wrapping in place, degrading to plain text when the window can't hold them. Type while the model streams — your input queues and is sent as a follow-up when the turn ends (mid-turn steering).
- Approval panel shows a real change preview; running tools display their own elapsed clock (
agent … · 47s) instead of a frozen screen; the status line stays minimal (tier, effective model, context usage). - Bilingual UI (English / 中文), hot-switchable with
/lang. Graceful shutdown end to end: SIGTERM/SIGINT drain properly, and process groups are killed as a tree — no orphaned dev servers squatting on ports.
Model routing
autopicks betweendeepseek-v4-proanddeepseek-flash(and the reasoning effort) per task, and falls back with retry on rate limits or upstream failures. Pin with/modelorprovider.model.
Image understanding
- Attach an image by pasting it, dropping a file in, typing
@in the file menu, or/image <path>.Ctrl+Vreads the system clipboard and attaches whatever image it carries — a screenshot, a file copied in Finder, a picture copied out of another application — storing it under<workspace>/.deep-code/images/, content-addressed so pasting the same shot twice is one file. The terminal's own paste key can attach an image too, by two separate routes, and which one applies depends on whether the clipboard carries any text at all: for a file copied in Finder it does (the file's name), so a paste that is exactly an image name we cannot open sends us to the clipboard for the file itself; for a screenshot or a picture copied from an application the clipboard is pixels alone, leaving the terminal nothing to send — and a terminal that forwards the empty paste anyway is telling us a paste was requested, which is the second route. A terminal that stays silent in that case leavesCtrl+Vas the only key that works, so that is the one to reach for when you want certainty. Dragging a file in works as it always did, because a terminal hands a dropped file over as its path. The composer shows a[图片 #N PNG]chip where the image sits, and/imagewith no argument lists what is attached and where it lives. - Only
deepseek-flashaccepts images, so a turn carrying one is routed there whatever the difficulty keywords, context pressure or cascade latch would otherwise say — the alternative is a request the API refuses. Pinning a model the catalog says cannot take an image behaves differently by surface, and the difference is deliberate: in the TUI the turn stops before it starts and your draft is kept, so the picture is still in front of you and one keystroke fixes it (/model flash, or take the image out); over-p/headless there is no draft to keep, so the image becomes a sentence in that turn's text ([图片未发送 / image not sent, …]) and the request still goes — refusing there would wedge the session, because a turn's images are recorded in the transcript and every later turn re-derives them, so a pinned model without vision would refuse every request after it, text-only ones included. The same mechanism is why switching to such a model mid-session keeps working: the images already in the history are sent as sentences rather than turning the session into a dead end.[vision] detailin the config picks how the image is processed (lowdownscales to 512×512 first;original/autosend it whole). - An image that cannot be sent never costs you the message. A file that has been moved or deleted since the turn was recorded, one that is not really an image (the format is read from the file's own bytes, not its extension), or one past the size and count caps becomes a sentence in that turn's text naming the file — the model is told what it is missing instead of being silently shown less. Pasted images are stored under
<workspace>/.deep-code/images/(already git-ignored) and are not cleaned up automatically — delete the directory by hand if you would rather they were gone.
npm i -g @liwenkai/deepcodeThe command is deepcode (postinstall downloads the platform binary from GitHub Releases and verifies SHA-256). To update:
npm i -g @liwenkai/deepcode@latestdeepcode # start a new sessionSet your DeepSeek API key on first run (or use the DEEPSEEK_API_KEY environment variable):
/apikey sk-...
deepcode # new session
deepcode -c # continue the latest session
deepcode -r # pick a session to resume
deepcode --new # explicitly new session
deepcode --add-dir DIR # grant an extra writable directory (repeatable; works with -p/serve too)
deepcode -p "..." # headless one-shot: run a full turn, print the answer (see below)
deepcode --help # command overview (--version prints the version)
deepcode doctor [--json] # environment self-check
deepcode serve --http # run as an HTTP server
deepcode eval # SWE-bench rollout (see below)
deepcode session list|resume|delete|export
Common slash commands: /help /model /apikey /lang /resume /clear /sessions /checkpoints /restore /agents /copy /add-dir /image (/help lists everything plus keybindings).
For "one project, several repos" setups (an SDK repo plus its host app), grant the sibling at launch:
cd ~/code/my-sdk
deepcode --add-dir ../host-app- Both layers open together: the file tools accept absolute paths that land inside a granted directory (relative paths always resolve against the primary workspace;
..and symlink escapes stay rejected), and the OS sandbox adds the directory as a write root — for shell commands, the credential-dir write denials (~/.sshand friends) still outrank every grant. The built-in file tools are bounded by the granted roots themselves, so only grant trees you mean to hand over. - Grants persist with the session: they are stored in the session record, so
deepcode -crestores the same boundary;--add-diron a resume merges in and is saved. The record sits inside the workspace, so it is a file the model can write — the grants in it therefore carry an HMAC keyed by~/.deep-code/session-key, and a list that does not verify is dropped on resume with a warning rather than restored — as is a list signed for a different workspace, so a record copied into another checkout cannot carry its grants there, and a recorded root whose spelling has come to resolve somewhere else (a symlink planted over an approved directory), which is dropped rather than silently redirected. (The key is out of reach of the model's file tools within the roots a session is given, and of a sandboxed shell on macOS. It is reachable three ways, each a consequence of something else stated on this page: a root you grant that contains it — a session started in$HOME, or--add-dir ~— because the floor deliberately does not police your own command line; a sandboxed shell on Linux, where Landlock cannot express a read denial, the same gap as Linux credential reads noted above; and any shell on Windows, which has no filesystem confinement at all. What the tag buys in all three is that forging a grant means reaching the key, rather than just writing the session file the model can already write.) To add a directory mid-task, run/add-dir DIRin the TUI — same validation, applied and persisted on the spot — or restart withdeepcode -c --add-dir DIR; when the model hits the boundary itself it can ask viarequest_write_root, and approving that prompt is the same grant, applied and persisted the same way. - Grants follow the run: within one
deepcoderun they stay with you — a new conversation started by/clearinherits the current grants (the startup banner and transcript always name the effective set). - Human decision only: there is deliberately no config key for this — a grant is the launch flag, the
/add-dircommand typed at the keyboard, or your explicit approval of the model'srequest_write_rootrequest. A malicious repo cannot self-grant through project config, and the request path has no automatic yes: no permission mode approves it (yoloincluded), config auto-allow and session memory are ignored for it, and its prompt deliberately offers no "approve for session". - Checkpoints cover the primary workspace only:
/restorerolls back just the workspace and says so when extra roots are granted (they are usually git repos themselves — roll back there with git). - Denials don't burn tokens: a write refused by the boundary is a failure no retry can fix, so it is handled as its own class — the first denial tells the model exactly that (teaching
request_write_root), three in one turn stop the turn with the same guidance for you, and none of them trigger the Pro escalation that ordinary repeated tool failures do.
deepcode -p "summarize this repo" # answer → stdout, all diagnostics → stderr
git diff | deepcode -p "write a commit message" # stdin is attached as data below the instruction
deepcode -c -p "continue: add the tests" # resume the latest session, then one shot
deepcode -p "fix this lint" --permission-mode accept_edits
deepcode -p "..." --output-format json # one {"result","reasoning","cost",...} object
deepcode -p "..." --output-format stream-json # NDJSON, same envelopes as the serve SSE- Same approval posture as the CI bot: a call that would prompt is auto-denied — never parked — with one stderr line per denial. Capability comes from the existing knobs:
--permission-mode accept_edits|auto|yolo,approval.auto_allow,DEEP_CODE_APPROVAL_AUTO_ALLOW— theautotier's Flash judge needs no human present, so it works headless. The deny floor stays non-negotiable. - Exit codes:
0finished /1error /2usage /124timeout (--timeout SECS) /130Ctrl-C. - One-shot runs persist a session too: stderr prints the id, and
deepcode -cpicks the thread up interactively any time.
Give any repository a /deepcode bot — comment → edit code → open a draft PR → reply in the thread — with one command:
cd your-repo
deepcode github install # writes the workflow, sets the secret via your own gh login
deepcode github status # show what is wired up--print previews without writing; --with-app also walks you through a GitHub App (below). Commit the file, push, done. No hosted service, nothing for anyone to keep running.
By hand it is just this:
on:
issue_comment: { types: [created] }
jobs:
deepcode:
permissions: { contents: write, pull-requests: write, issues: write }
uses: liwenka1/deep-code/.github/workflows/deepcode-bot.yml@main
secrets:
deepseek-api-key: ${{ secrets.DEEPSEEK_API_KEY }}Optional bot identity. Without an App the bot works fine, acting as github-actions[bot]. Configuring your own GitHub App (--with-app walks you through it) buys two things: commits count toward contributors and comments carry a [bot] badge; and the bot's pushes trigger your other workflows, which a GITHUB_TOKEN push never does — meaning without an App, bot PRs get no CI checks.
Trigger prefix, who may trigger it, language, model and permission tier are all configurable — see the header of deepcode-bot.yml. For your own pipeline (PR review, issue triage, scheduled maintenance) you don't need this workflow at all; two lines do it: npm i -g @liwenkai/deepcode then deepcode -p --output-format json.
Think before widening who can trigger it. Only the repository owner can by default (
allowed-associations). Opening that up hands anyone who can comment the ability to run shell in your CI, next to your secrets. Safety rests on three legs — trusted triggers, the CLI's deny floor, and PRs never auto-merging — and removing one is not carried by the other two.
Config file: ~/.deep-code/config.toml (see config.example.toml in the repo root).
Load order: built-in defaults → global → project .deep-code/config.toml → environment variables → CLI flags.
Common keys: provider.model (pro/flash/auto), provider.reasoning_effort (off/low/medium/high/max), cost.currency, approval.auto_allow (pre-approved tool names, matched exactly — never prefixes).
Keep the API key in an environment variable or the global config;
api_keyin project-level config is ignored, so it can't leak with the repo.
DEEP_CODE_DISABLE_WEB: set to 1/true/on to disable web tools (web_search/fetch_url) for offline or audited environments. Enabled by default; /status shows current web=on|off.
On macOS / Linux, shell/job commands run inside the OS sandbox with no network by default (see Highlights for details). [sandbox] network = prompt|always|never tunes this, project config can only tighten. On Windows there is no sandbox confinement — the no-network and write-confinement guarantees do not apply (network is a no-op); declared network still routes to approval and the deny floor still applies. Run deepcode doctor for the ground truth on your machine.
deep-code does not ship MCP. It already has a shell, so the way to add a capability is a script/command + a SKILL.md: a one-line summary lives in the system prompt, the model reads the SKILL.md body on demand, then calls your script through the shell tool.
- Discovery: drop a
SKILL.mdwithname/descriptionfrontmatter into a skills directory (global~/.deep-code/skills/<name>/or project<workspace>/.deep-code/skills/<name>/); the one-line summary is always in the prompt, the body only enters context when relevant. - Execution: a capability is just a normal command (
psql,curl, your own script, …) run through theshelltool — subject to the same approval gate and execution policy as everything else. - Why no MCP: for an agent that has a shell, the shell is the universal tool protocol. A few dozen tokens of SKILL.md summary loaded on demand beats keeping a full tool schema resident in every request. Pipe (
| head, …) to trim results so only the relevant slice enters context. If you need an existing MCP-ecosystem server, use a host that speaks MCP — deep-code stays lean.
Built-in SWE-bench rollout driver: pulls the official dataset, drives the agent through each task to produce patches, and writes an official-format predictions.json. No local scoring — a produced patch ≠ resolved; the real resolved rate comes from the official evaluation (sb-cli in the cloud, no local Docker).
# Requires a configured DeepSeek API key (errors out instead of dry-running)
deepcode eval --sample 2 # shakedown: 2 tasks from the dev split
deepcode eval # full dev split (23 tasks, a few cents)
deepcode eval --split test --parallel 4 --timeout 900 # full test split (300 tasks)Artifacts land in eval-out/ (change with --out): predictions.json (official format) + report.json (per-task time, cost, model and routing source).
Official scoring:
pip install sb-cli
sb-cli gen-api-key you@example.com # one-time; after email verification export SWEBENCH_API_KEY=...
sb-cli submit swe-bench_lite dev --predictions_path eval-out/predictions.json --run_id my-runNetwork note: the dataset comes from the HuggingFace datasets-server; some networks need HTTPS_PROXY=http://127.0.0.1:<port> or DEEP_CODE_HF_BASE pointed at a mirror. Parameter details in crates/deep-code-eval/README.md.
git clone https://github.com/liwenka1/deep-code
cd deep-code
cargo build --release -p deep-code-tui
# artifact: target/release/deep-codeMIT