Skip to content

v0.4.0

Latest

Choose a tag to compare

@github-actions github-actions released this 25 Oct 17:43
3421a3c

Multi-model runtime

  • Native Kimi and GLM profiles share provider routing, model budgets and scoped
    credentials across the CLI and app-server clients. Unknown prices are shown
    as unavailable. See multi-model configuration.
  • Claude Agent SDK 0.3.310 is an explicit experimental engine, with controlled
    DeepCode tools, approvals, snapshots and isolated sessions. Native remains the
    default. To roll back, select engine: "native" and start a new session;
    existing SDK records are retained.
  • Truncated responses and exhausted turn budgets report incomplete execution.
    Summaries inherit the selected model and cancellation, credentials follow the
    documented precedence, and user prompts can be cancelled.
  • File mutations recheck permissions after approval; review restore respects
    file restrictions, and SDK rollback records point to the pre-edit snapshot.
    Hook processes and SDK resources are cleaned up when work stops. Cancellation
    during the final path check or file preparation no longer starts a later write.
    Additional writable directories work with the default sandbox mode; SDK
    session forks are rejected before copying incomplete history.
  • Real SDK executable tests run on Ubuntu and macOS against local fixtures;
    Linux bwrap/netns, desktop protocol journeys and release gates also pass.
    Real Kimi/GLM endpoints and long-task quality still need live acceptance.
  • The VSIX uses native by default and does not bundle the optional SDK
    executable. Signed macOS installers and npm publication require the release
    credentials described in Releasing DeepCode.

The following changes accumulated on main since v0.3.0 are also included.

⚠️ Breaking

  • deepcode mcp serve now applies your permission settings. It executed
    Read/Write/Edit/Bash for any connected MCP peer with no mode, no permission
    rules, no file contract and no PreToolUse hooks — the same shape as the
    runAgent bypass fixed in #181, in a different entry point. Every call now
    goes through the central gate, a call that would need approval is refused
    (nobody is attached to that pipe to approve it), and a permissive
    permissions.defaultMode is clamped to default exactly as a scheduled job's
    is. A peer can now do what permissions.allow says it can and nothing else,
    so anyone relying on the old behaviour must add rules — or start the server
    with an explicit --mode. --sandbox also applies now; it did not before.

✨ Added

  • Grep and Glob results in the desktop are now clickable locations. Both
    tools attach the places they found as structured data — resolved from their
    own parsed rows, where a path containing : is still unambiguous — and the
    desktop renders a search's card as a list of openable paths (with line and
    matched text in content mode) instead of a grey text blob. Clicking one opens
    it in the file panel. This is the locations render intent the DSH round
    specified and deferred: re-deriving entries by parsing the formatted result
    text was the rejected design, and attaching them at the source is what makes
    the entries exact. A session restored from its log has only the text the
    model saw, so replayed cards degrade to today's plain-text body.

  • /plan enters plan mode directly (/plan off leaves it) — the same
    switch /mode plan already threw, one word shorter and matching what Claude
    Code users type. A trailing prompt (/plan do X) is refused with a pointer
    rather than silently dropped, because half-obeying a command teaches the
    wrong lesson about what it did.

  • Persistent shells now last a whole CLI session, and /shells shows them.
    The registry landed in #273 owned by a single runAgent call, which meant a
    shell opened in one turn was gone by the next — a slower Bash with extra
    steps. The REPL now owns one registry for the session and threads it through
    every turn, so cd, export, and an activated virtualenv survive from one
    message to the next.

    /shells lists what is open and where each started; /shells close <id>
    closes one and whatever is still running in it. Worth having because the
    change is what makes these processes outlive a turn: without a view of them,
    the user has no way to see or stop something the agent left running.

    Everything closes when the session ends, including when it ends by throwing —
    these are real processes in their own process group, so they do not die with
    the CLI. Background tasks and sub-agents deliberately do not share the
    session's shells: two agents interleaving commands in one shell would each be
    wrong about its state.

  • A shell that survives between tool calls (#273) — ShellOpen / ShellRun /
    ShellClose / ShellList. Every Bash call is a fresh process, so cd,
    export, and source venv/bin/activate were forgotten the moment they
    returned; the workaround was re-pasting the whole prefix into every command,
    which still could not hold a running dev server.

    Not a PTY. That would mean node-pty, a native dependency needing a build for
    every platform the desktop ships to, bought for full-screen programs that are a
    small share of the value. This runs over pipes with a per-session random
    sentinel, and says plainly in its description that vim and top do not work.
    Commands run with stdin closed so cat cannot swallow the sentinel, and stderr
    is merged at the shell so the streams interleave in true order.

    Interrupting an overrunning command signals the shell's children, not its
    process group — signalling the group kills a non-interactive bash, which would
    lose the session every time. Lifetime is guaranteed rather than remembered: a
    run that opens its own registry closes every shell before returning, including
    when the loop throws. Idle shells close themselves and there is a cap on how
    many can be open.

  • The agent can search its own past sessions (#272) — SessionSearch finds
    text across previous sessions and SessionRead follows a hit into the
    surrounding conversation. Every session was already on disk as JSONL and
    nothing could read it back, so "how did we fix this last month" was
    unanswerable while the answer sat in a file the agent wrote.

    Scoped to the current workspace by default, matched on a resolved path boundary
    so /a/project cannot capture /a/project-two. Neither tool takes a scope
    argument
    : widening the search is a privacy decision, and a parameter would let
    the model consent to reading another project's history on the user's behalf.
    SessionRead enforces the same rule, so knowing an id is not authorisation. No
    index — a scan is fast at this volume, and an index is a second copy of the
    truth that can disagree with it.

  • Tools declare how they should be drawn (#271). Every tool call rendered as
    the same grey blob: an Edit showed its result sentence but never the change,
    a Bash showed output with no sign of which command produced it. A tool now
    declares a render intent beside its schema — Bash is terminal,
    Edit/Write/NotebookEdit are diff — and the desktop draws real coloured
    +/- lines and a shell transcript. The mapping lives in core so the CLI and
    the extension read one answer instead of each hardcoding tool names.

    Presentation is a pure function of the call's arguments, never the result or
    the filesystem, so a replayed session renders as it did live. That is why
    Write renders as wholly added: its arguments genuinely do not say what the
    file held before.

  • A backstop deadline on every tool call (#270). Only Bash bounded itself;
    a Grep against a stalled mount could hang the turn forever with nothing on
    screen but a blinking cursor. Deliberately generous (10 min default) so each
    tool's own limit fires first and produces the more specific error — this only
    covers the case where the inner limit never fires. A caller-requested timeout
    always wins when longer. On a side-effecting tool the message says the effect is
    unknown rather than implying nothing happened.

  • The loop notices when the model is repeating itself (#269). The most
    expensive agent failure is the loop that neither errors nor terminates: same
    tool, same arguments, same answer, quietly spending the budget. At 3, 5, and 8
    consecutive identical calls an escalating reminder is injected. It has no veto,
    which is what makes false positives affordable. Excluded tools are transparent
    to the chain rather than resets, so a bookkeeping call interleaved into a loop
    cannot launder it, and calls the gate refused still count.

  • thread/delete — the protocol could list, fork and archive threads but not
    delete one, so the desktop deleted session files through Tauri instead. The
    app-server is the single owner of thread storage; a client removing files
    behind it can pull the ground out from under an open writer. Served under the
    existing threadManagement capability, with the local writer kept as the
    fallback for a sidecar too old to know the method.

  • Change ledger records provenance. Each entry now carries derivedFrom —
    the files the turn read before making that change — answering the question
    after "what changed" and "how do I undo it": what was it derived from. That
    is what you ask when a generated file is wrong and you need to know which
    input to fix. Shown by deepcode ledger show. Built on the existing ledger
    rather than a second store.

    It is observed, not declared: only Read counts (Grep/Glob take a search
    root and return many paths — calling the root an input claims a derivation the
    turn did not make), a failed read is not an input, and the file being written
    is excluded so an Edit does not look self-derived. Absent rather than empty
    when there is nothing to say.

  • Trigger sources for scheduled jobs — a job can now fire from a calendar
    file or a file change, not only a clock. { "kind": "ics", "path": "team.ics", "match": "release" } fires when a matching event starts;
    { "kind": "file", "paths": ["schema.json"] } fires when a watched path
    changes. schedule still means cron and existing jobs need no migration.
    Everything is polled by the existing scheduler run, so there is no
    daemon and no way for a trigger to fire while nothing is listening. See
    docs/triggers.md.

    Standard iCalendar text is the only calendar input — no vendor SDK, no OAuth
    to a calendar service. The reader handles DTSTART, folded SUMMARY lines and
    RRULE FREQ=DAILY/WEEKLY with INTERVAL/BYDAY/UNTIL/COUNT, and
    reports anything it cannot express rather than dropping it: a silently
    ignored RRULE is a job that never fires, and that failure is
    indistinguishable from "nothing was scheduled". All-day entries never fire —
    they name a day, not a moment. A trigger decides when, never what may happen:
    every scheduled run still goes through the unattended clamp.

  • The in-app updater has a feed. tauri.conf.json has had
    updater.active: true and a committed public key since the desktop app
    shipped, pointing at a latest.json that nothing ever produced — so the app
    polled, 404ed, and silently never updated. The release pipeline now enables
    updater artifacts, signs them, writes the manifest and attaches it, all gated
    on a signing key being present so a credential-less release still builds. When
    the key is absent the release body says the feed is missing, because an
    updater polling a 404 forever looks identical to one that has found no update.
    Generating the key pair remains yours — see docs/RELEASING.md.

🔒 Security

  • A sub-agent did not inherit the file contract. The Task delegation
    forwarded mode, permission rules, hooks, sandbox config and auto-mode — every
    gate except the contract. So "never read secrets/**" bound the main agent
    and said nothing to the sub-agent it spawned to do the reading, and since a
    contract deny is deliberately not waivable, this was the one gate that was
    supposed to hold no matter what. A regression test asserts the secret never
    reaches the provider.
  • Grep and Glob returned results the contract denies reading. Both take
    a search root, so the pre-call verdict only ever covered where the search
    started; a search rooted at the workspace was allowed and then handed back
    matches from denied paths, with the matched line attached. Results are now
    filtered through the same evaluatePath the gate uses — no second glob
    dialect to drift — and the output ends with a count of what was withheld,
    never with the paths. ask is not filtered: mid-search there is nobody to
    ask, and a hit is not yet a read.
  • The plugin capability bridge passed no contract into the tools it executed, so
    a plugin's Grep skipped the same filter.
  • --sandbox read-only was not read-only on Linux. buildLinuxBwrapArgs
    ended with an unconditional --bind <cwd> <cwd>, and bwrap applies binds in
    order with the last one winning — so the read-only bind that the mode had
    correctly asked for was overwritten a few arguments later, and a command could
    write to the workspace. macOS never had this: its profile grants writes only
    from allowWrite, which read-only leaves empty. #226 introduced the mode axis
    and verified it on macOS; this is the half nobody looked at. Callers using the
    legacy enabled: true shape are unaffected.

🐛 Fixed

  • A rethrown error keeps the one that caused it. Five places wrapped a
    caught error in a new Error carrying only its .message — settings and
    trust-store loading, the hook trust file, and the MCP headersHelper — so a
    parse failure or a spawn error arrived with the original stack, errno and
    path discarded. They now pass { cause }, and ESLint's preserve-caught-error
    keeps the next one from being written.

  • Tool output could flood the model's context, or vanish (#268). Two defects,
    one cause: nothing central bounded what a tool result put in front of the
    model, so each tool improvised.

    WebFetch improvised by not bounding at all — it returned the whole response
    body, capped only at 5 MiB of bytes. A 5 MiB page is roughly 1.5M tokens; one
    call ended the session. Bash improvised by destroying evidence: it sliced at
    30 KB and wrote the remainder nowhere, and the tail of a failing test run is
    exactly the part worth reading.

    A spill policy now runs on every tool result. Anything at or under the
    threshold passes through untouched; anything over becomes a head-and-tail
    preview naming the file holding the full text, saved beside that session's
    snapshots and retrievable with Read. Both ends, weighted toward the tail,
    because stack traces and exit codes live at the end. A host with no filesystem
    still gets the bound and is told the output was not saved rather than shown a
    path that does not exist.

  • Release notes come from the CHANGELOG. gen-release-notes.ts walked the
    commit range, and with no preceding tag it fell back to the root commit — which
    is how v0.3.0's release page came to say "0 commits." after #250 fixed the
    shallow clone. It now takes the tagged version's CHANGELOG entry, which is
    written for humans and groups changes by what they mean rather than by the verb
    the commit happened to start with. Repo-relative links are rewritten to
    absolute URLs pinned at the tag, since a release body does not render inside
    the repository. Falling back to commits still works and says so in the body.

  • The desktop sidebar was a second reader of the session directory. Archive
    and delete went through Tauri while the protocol served the same threads, and
    the list did too — window.deepcode.sessions.list() had preferred the
    protocol since #231, but Sidebar.tsx bypassed the shim and called
    listSessions() directly. All three now go through the owner, and deleting a
    thread removes both its protocol snapshot and its canonical session
    projection: list reads both, so removing one left the row reappearing on the
    next refresh as an empty session that could not be opened.

  • deleteSession refuses a session id that is not a single path segment, before
    removing anything. It ends in a recursive delete of <root>/<id>, and .. —
    the id that resolves to the directory above the sessions root — is spelled
    entirely in characters an id may legitimately contain, so the character-class
    check both it and the thread store relied on admitted it. Every in-tree caller
    validates first; a delete this destructive should not depend on that.

  • Grep over a single file no longer prefixes every line with a colon.
    ripgrep omits the filename when the search path is one file — there is
    nothing to disambiguate — so its --null output carries no NUL, and rejoining
    the record as path:text with an absent path emitted :1:hit. Parsing now
    distinguishes "rg printed no path" from "rg printed an empty field", and the
    separator is written back only where rg wrote one. Such a row is attributed to
    the search root for contract filtering, so the result filter does not depend on
    the pre-call gate having adjudicated that call correctly.

  • CI installs ripgrep and sets DC_REQUIRE_RIPGREP=1. The Grep suite
    self-skips when rg is absent, so it may never have run in CI — and it now
    covers ripgrep's --null output format, which the tool parses byte for byte.

  • The test suite could re-initialise your own repository. git reads
    GIT_DIR from the environment and a git hook sets it, so a fixture calling
    git init on a temp directory from inside the pre-commit gate did not
    initialise the temp directory — it re-initialised the developer's checkout as
    bare and wrote the test identity into its config, after which every git
    command there failed with "this operation must be run in a work tree".
    apps/server/src/workspace-diff.test.ts was the fixture; the code it tests
    scrubs the environment, the fixture did not. Three other fixtures had each
    grown their own copy of the scrub, two carrying a comment describing this
    precise failure — a convention passed by word of mouth that had stopped being
    enforced. They now share gitSpawnEnv, and a check fails the build if a test
    spawns git without it.

  • The CLI is published as @oratis/deepcode. 0.3.0 renamed it away from
    deepcode-cli because that name belongs to an unrelated project — but
    @deepcode/cli was not ours either. The leaf name is unpublished, which is
    what made it look free; the @deepcode scope holds @deepcode/tsc and
    @deepcode/dcignore, and npm rejects a publish into a scope you do not own. A
    scope is not claimable by publishing into it, so pnpm publish would have
    returned the same 403 the rename was meant to fix. The new name is the
    repository owner's personal scope, which needs no organisation to exist first.
    Nothing was ever published under either old name, so no installed package
    changes. The binary is still deepcode.

  • A test now asserts that every npm i -g … in a current document or in CLI
    source names the package apps/cli/package.json publishes. Both renames so far
    moved some install strings and left others behind. It scans the whole
    repository minus an explicit list of historical snapshots, rather than an
    allowlist of the documents somebody thought of — an allowlist has to be
    extended by whoever adds the next document, and stays silent when they forget,
    which is the same shape as the bug it is there to catch.

  • apps/cli/README.md — the npm landing page — still described the CLI as an
    "M0 骨架,命令入口存在但不能用" and pointed at milestone numbers for when
    features would arrive. It shipped in the package files list.

📄 Documentation

  • DeepSeek Harness research and adoption plan (#267) —
    docs/research/deepseek-harness.md grades
    every claim A/B/C (source read / their docs say so / hearsay, inadmissible),
    and docs/DSH_ADOPTION_PLAN.md argues each
    candidate proponent / opponent / verdict so the implementation PRs execute a
    decision rather than reopen one. dsh is the closest comparison DeepCode has:
    another DeepSeek-powered coding agent under the same constraints.

🚫 Deliberately not adopted

A Cordis-style "everything is a plugin" rewrite: dsh spends 219 packages
expressing what DeepCode expresses in 4, and that ratio is what shipping a
third-party plugin ecosystem costs — we do not ship one, so we would pay the cost
and collect none of the benefit. It also self-describes as a developer preview
that will break compatibility, while 0.3.0 is out with npm, VSIX, DMG and an
update feed downstream. Take the discipline, not the framework.

Also rejected: its workflow engine, which introduces model-written code executed
behind a boundary its own docs say is not a security boundary, with no use case
our sub-agents fail to cover. Deferred pending a product decision: the goal
domain and the Ralph loop, which change when an agent stops rather than what it
can do. Reasoning in the adoption plan §2.


No macOS build in this release. Apple signing credentials were not configured when it was cut; see docs/RELEASING.md.

Not published to npm. NPM_TOKEN was not configured when this release was cut; install from source or the VSIX.