pi-dsh

August 15, 2026 · View on GitHub

Pi Coding Agent provider extension that exposes the DeepSeek Harness (dsh) as a selectable provider. Selecting the dsh provider routes each turn to DSH's own harness, running an in-process cordis tree with a persistent per-conversation session pool instead of an HTTP LLM endpoint.

Context ownership = DSH. DSH natively loads its own system prompt, AGENTS.md (~/.dsh/AGENTS.md + the project AGENTS.md chain via dsh-agent-instructions), and skills (~/.agents/skills, ~/.dsh/skills, project .dsh/skills + .agents/skills via dsh-skill-filesystem). pi-dsh does not re-forward pi's AGENTS.md/skills — that would double-load.

Requirements

  • A Pi Coding Agent host (@earendil-works/pi-ai / @earendil-works/pi-coding-agent >= 0.82.0)
  • A real DSH install for pool mode (~/.dsh/profiles/headless + credentials); dsh on PATH for the oneshot fallback
  • Node.js >= 22 for running the verification scripts (test/verify-pathb.mjs imports a standalone .ts module via native type stripping)

How it works

  • Registers a provider dsh with a custom api: "dsh-exec" and a non-HTTP baseUrl: "dsh-exec://local".
  • Pool mode (default, mode: "pool"): boots one in-process DSH cordis tree per process (the dsh-base composition, with the HMR row disabled), then keeps a pool of live agents — one DSH conversation per Pi conversation, keyed by Pi's session id (LRU cap 8, 15-min idle TTL). Each turn is agent.followup(...), and the session/event firehose is translated into Pi's AssistantMessageEventStreamtext_delta / thinking_delta, with tool activity shown as thinking blocks — so the answer streams live, not as one block.
  • One-shot fallback (mode: "oneshot"): the pre-path-B behavior — spawn dsh --profile headless "<user text>", collect stdout, emit one text block. Kept config-selectable in case the rc tree misbehaves.
  • DSH owns prompt/memory/skills; pi-dsh only routes the latest user message. Cross-turn continuity comes from DSH's session persistence (~/.dsh/sessions JSONL): a resumed Pi conversation resumes the same DSH session (via deterministic session ids, so continuity even survives a host restart).

Permissions

The embedded DSH tree boots with sandbox danger-full-access + approval never, patched at boot in src/dsh-host.ts (dsh-base would otherwise default to workspace-write + approval ask).

This is an install-time trust decision, not a per-action gate. DSH runs its own tool loop (bash/fs/subagents) inside the provider, and that activity is surfaced as thinking blocks — Pi's approval UI never sees or gates these calls, by design (forwarding them as Pi toolcall_* events would hand tool execution to Pi's agent loop and re-drive the turn forever). So once pi-dsh is installed, the harness acts with your full local access and nothing — neither Pi nor DSH — prompts before the model writes files or runs commands. This mirrors pi-claude-bridge's permissionMode: "bypassPermissions" and pi-cursor-sdk's SDK embedding (no permission dialogs at all).

The two settings are a pair, and only one of them is what actually lets tools run. danger-full-access permits everything at the sandbox layer, so nothing ever needs to escalate. Approval never does not mean "auto-approve" — dsh-user-approval resolves it to rejected — it means escalation requests die deterministically and the model is told up front not to attempt sandbox_permissions.

  • DSH's file sandbox does not restrict writes from the embedded tree.
  • Network/process policy is outside DSH's SandboxMode vocabulary in any mode.
  • Confining the tree (e.g. workspace-write) requires bridging approval requests to Pi's permission UI first, and is a deliberate future change. Simply lowering the sandbox mode does not work: ask invites the model to escalate, but with no answerer mounted every request fails closed as unavailable, so it burns steps on retries that can never succeed.

Install

Published on npm:

pi install npm:pi-dsh

For local development, add the absolute path of this repo to ~/.pi/agent/settings.jsonpackages:

{
  "packages": [
    "<absolute-path-to-this-repo>"
  ]
}

Then restart pi (or your pi host). The dsh provider appears in /model as dsh/<model> — the model DSH itself is configured to run (~/.dsh/settings.yamlagent-default-model, e.g. dsh/deepseek-v4-pro). When that file is unreadable the catalog falls back to a synthetic dsh/dsh-harness entry. DSH owns model selection; pi-dsh mirrors it.

Dependency layout

@deepseek-ai/* are pinned (0.1.0-rc.6) pi-dsh dependencies and live in this repo's node_modules as real installs, isolated from the pi host's dependency graph (dsh pulls zod@4, which must not be hoisted over a host that pins zod@3). The pi host packages and dev tooling resolve through scope-level symlinks into the host install. See plans/002-design-notes.md for the exact layout and rationale.

Development

npm install            # installs pinned @deepseek-ai/* deps + dev tooling
# point the pi host packages at your pi install (single physical pi-ai instance):
ln -s <your-pi-install>/node_modules/@earendil-works node_modules/@earendil-works
npm run typecheck      # tsc --noEmit
npm run verify         # provider registration check
npm run verify:stream  # oneshot-mode regression (fake dsh)
npm run verify:pathb   # pool-mode integration (real dsh; SKIPs if absent)

Configure (optional)

~/.pi/agent/dsh.json:

{
  "mode": "pool",
  "poolMax": 8,
  "poolIdleTtlMs": 900000,
  "dshBin": "dsh",
  "timeoutMs": 1800000
}
  • mode: "pool" (default) or "oneshot".
  • poolMax: max pooled sessions (LRU-evicted beyond this). Default 8.
  • poolIdleTtlMs: idle time before a pooled session closes, ms. Default 15 min.
  • dshBin: used only by the "oneshot" fallback. Default dsh.
  • timeoutMs: used only by the "oneshot" fallback (per-run budget). Default 30 min.

Env overrides: PI_DSH_MODE, PI_DSH_POOL_MAX, PI_DSH_POOL_IDLE_TTL_MS, PI_DSH_BIN, PI_DSH_TIMEOUT_MS.

Pinning policy

The in-process DSH surface is unstable (0.1.0-rc.6, SESSION_FORMAT_VERSION = 0, "no compatibility is implied"). @deepseek-ai/* are pinned to exact versions in package.json (no ^), and every @deepseek-ai/* import is isolated behind src/dsh-host.ts (the single chokepoint). Any dsh upgrade (0.1.0-rc.x → newer) requires re-running the Phase 1–2 spike gates (recorded in the repo's plans/ notes — not shipped in the npm tarball) before bumping the pins — the assistant/chunk / turn/end event vocabulary has no compat guarantee.

Limitations

  • Pool mode requires a real DSH install (~/.dsh/profiles/headless + credentials in ~/.dsh/.credentials.yaml). If the rc tree misbehaves, set mode: "oneshot" to fall back to the stateless subprocess path (no continuity, no streaming).
  • Abort cancels the live turn but preserves the pooled session; an entry is destroyed only on a real turn error or by the idle-TTL/LRU sweeper.
  • Tool calls run inside DSH's own tool loop and appear as thinking blocks AT their chronological position ([tool] … on call, [tool result] … on completion) — so the stream interleaves by time (think → tool → think → tool) instead of piling tools into a separate area. They are never Pi toolcall_* events: foreign toolCall blocks make Pi's agent loop take over tool execution with empty toolResults and re-drive the turn (an infinite loop).
  • Usage is reported per turn: DSH's usage chunks are per model call (verified against persisted sessions), so pi-dsh sums the turn's chunks; totalTokens stays prompt-side so Pi's auto-compaction never misfires.

Verification

npx tsc --noEmit            # typecheck
node verify.mjs             # provider registered
node verify-stream.mjs      # path-A (oneshot) regression vs fake-dsh.sh
node test/verify-pathb.mjs  # path-B pool integration (real dsh; SKIP if absent)