dsh-pi-adapter
Run pi coding-agent extensions
(~/.pi/agent/extensions/*.ts) inside
DeepSeek Harness (dsh).
A pi extension is export default (pi: ExtensionAPI) => void; every capability
comes from the injected ExtensionAPI object. This project implements that
contract on a cordis context so pi extensions run unmodified inside dsh.
How it works
pi extension (.ts factory) dsh (cordis)
┌────────────────────────────┐ jiti ┌──────────────────────────────┐
│ export default (pi) => { │ ───────► │ ExtensionBridge (PiCompat) │
│ pi.registerTool({...}) │ alias │ registerTool → ctx.tools │
│ pi.registerCommand(...) │ to pi │ registerCommand → commands │
│ pi.on("tool_call", fn) │ runtime │ on(event) → ctx.on(map) │
│ } │ │ dispose() → unwind effects │
└────────────────────────────┘ └──────────────────────────────┘
- jiti loader loads extensions the same way pi does (TS, no build step).
- pi runtime aliases resolve
@earendil-works/pi-ai/pi-tui/pi-coding-agent/typeboxfrom the global pi install (npm root -g), since those packages only exist there. - ExtensionBridge implements the full
ExtensionAPIsurface. Each method is one of:[direct]shape matches a cordis seam one-to-one[adapt]a compatibility adapter wraps the dsh surface[degrade]a write-only sink with no dsh equivalent — logs loudly[unsupported]no truthful answer exists — raisesUnsupportedSeamError
The answer-truthfully rule
The bridge never fabricates a value an extension will act on. The split is by direction, not by importance:
- a seam the extension reads (its return value drives a decision) with no truthful dsh answer → throw, naming the extension and the seam
- a seam the extension only writes (
notify,setStatus, widgets, shortcuts) → log and continue; nothing branches on it
The nuance is concrete: permission-gate.ts gates on ctx.ui.select(), and
a headless dsh host has no user to ask. Two wrong answers were on the table.
Silently returning undefined with nothing in the log hides the missing
capability; quarantining the extension (the earlier behavior) kills the gate
for the rest of the session — every later call denied with a misleading
"unimplemented seam" reason, which is what a headless rc.2 smoke run actually
produced. pi's own contract offers the honest middle: select/confirm
resolve undefined/false on user-cancel, and correctly written extensions
treat that as non-affirmative (the same branch they take under
!ctx.hasUI). So an UNANSWERED ask resolves to that no-selection convention,
with a loud log naming why nobody answered — a gate that blocks unless the
answer is an explicit "Yes" (the real permission-gate shape) keeps gating
with its own precise reason. A gate written to block only on an explicit
"No" allows on no-selection — but that shape fails open on a plain Esc
under real pi too. strictUi: true restores raise-and-quarantine for
deployments that prefer it. Seams with no truthful answer at all
(ctx.ui.editor(), ctx.isProjectTrusted()) still raise.
ctx.ui.theme moved the other way: pi's Theme is pure string decoration, so
rendering each span as its own plain text is faithful for a host with no ANSI
surface, not a degradation.
When an extension hits an unsupported seam
| where | what happens |
|---|---|
| its factory (mount) | partial registrations unwound, extension not mounted |
a tool_call gate |
the call is denied, permanently — a gate that cannot run never becomes one that permits |
a tool_result handler |
the tool's own result passes through unchanged |
| a tool body / command | that invocation fails with the seam named |
| any other handler | logged; the extension is quarantined |
Quarantine disables the extension without unsubscribing it, so its tool-call
gates keep denying instead of vanishing. Both moments are also appended to
the session log as log-only audit events — pi-adapter/quarantine (extension,
seam, call site) and pi-adapter/gate-deny (extension, tool name, callId,
reason) — so a replayed session tells the operator which extension was
quarantined and which calls its broken gate went on to deny. In a
multi-session host each event lands on the session of the agent whose call
was denied (a dsh Agent exposes .session), falling back to the last
observed session only when no agent context exists. The events never
enter the model surface; the stderr log line remains the floor when no
session has been observed yet.
One upstream limit, stated plainly: none of the adapter's plugin-owned event
types — pi-adapter/quarantine, pi-adapter/gate-deny, and the existing
pi/entry / pi/label from pi.appendEntry/setLabel — are in dsh
session-persistence's KNOWN_SESSION_EVENT_TYPES, and Session.append()
has no way to mark an appended event ignorable. A session containing any
of them therefore cannot resume under a standard harness with
session-persistence mounted: the read path refuses the unknown type with
SessionFormatUnsupportedError (deliberately — an unrecognized log might
belong to a newer harness, and dsh over-refuses rather than resuming a
session it cannot interpret). A feature request has been filed upstream for
a plugin-owned event registration surface; until it lands, the audit trail
is exact for live sessions but costs resumability.
onUnsupported: 'fail' refuses to start instead of quarantining. It propagates
wherever a caller exists (mount, tools, commands, the tool-call waterfall); a
fire-and-forget event listener has no caller, so there it degrades to the same
loud log.
Translation tiers
adapt-interactive sits between adapt and unsupported: the seam exists
in dsh, but only behind a capability the HOST may or may not mount — when an
interactive host mounted a user-questions provider the adapter asks the
real human; when it has not, the adapter answers with pi's no-selection
convention (undefined/false, loudly logged), which extensions already
treat as non-affirmative — never an invented answer. strictUi: true turns
an unanswered ask into a seam error instead.
| pi API | dsh seam | tier |
|---|---|---|
pi.registerTool({parameters, execute}) |
ctx.tools.register() with mandatory output {schema, render}; typebox → parameters JSON Schema (1.x's non-enumerable ~kind/~optional/~unsafe metadata is stripped — dsh requires lossless JSON and rejects the whole catalogue otherwise); execute(callId, args, signal) arg bridge |
adapt |
pi.registerCommand(name, {handler}) |
ctx.commands.register({name, description, handler(invocation)}); the registration window is cordis's own DI: a ctx.inject(['commands']) child flushes buffered registrations the moment the commands fiber goes ACTIVE (during boot that is after plugin mount, so a mount-time probe always loses — dsh's plan-mode registers commands the same hook). Load completion warns how many commands are still buffered; a host without dsh-commands is caught at the first session/created checkpoint, which degrades each buffered command by name without dropping it (a late-activating service still registers it) |
adapt |
pi.registerFlag / getFlag |
in-process registry | direct |
pi.on("tool_call") (block) |
tools/pre-execute waterfall — block → {kind:'deny'}; non-block delegates next(); event.input is a mutable clone of dsh's frozen arguments, so in-place rewrites are dropped (dsh decisions can't rewrite args) instead of throwing |
adapt |
pi.on("tool_result") |
tools/post-execute waterfall — returned content → {kind:'accept', content} |
adapt |
pi.on("turn_start/end") |
session/event stream filtered to turn/start / turn/end |
adapt |
pi.on("session_start/shutdown") |
session/created / session/disposed |
direct |
pi.on("agent_start/end") |
agent/session-start / agent/settled (closest verified events; payload shapes differ) |
adapt |
pi.on("input") |
agent/prompt-submit waterfall — carries the claimed UserMessage, so event.text is real; {action:'transform'} → {kind:'allow', content}, {action:'handled'} → {kind:'block'}. A handler that cannot run delegates rather than blocking: this admits the USER's own prompt, so failing closed would wedge the session |
adapt |
pi.on("before_agent_start") |
agent/prompt-submit — pi's "prompt settled, turn about to open" is the same edge. event.systemPrompt is absent (dsh assembles it asynchronously later); result-side prompt/message replacement degrades |
adapt |
pi.on("session_compact") |
compact/end without error in the session/event stream. reason: 'manual' only when the owner turn is null (a standalone manual transaction); compactionEntry/fromExtension/willRetry are omitted, not invented |
adapt |
pi.on("agent_settled") |
agent/status reaching idle. NOT agent/settled: that fires per terminal turn and still flickers mid-retry, whereas idle means "parked, waiting for queued work" — pi's actual "no retry/compaction/follow-up pending" |
adapt |
pi.events extension bus |
cordis pi-ext/* namespaced events (and emit is real, not a silent no-op) |
adapt |
ctx.isIdle |
live agent.status read |
direct |
ctx.hasPendingMessages |
inbox depth tracked through agent/inbox/* lifecycle events |
adapt |
ctx.abort() |
agent.cancel({kind:'user'}) — pi's abort is a human interrupt |
adapt |
ctx.compact() |
ctx.compact.compactNow() with the agent's reserveTurnAdmission(); fire-and-forget, failed attempts stay durable in the log |
adapt |
ctx.getContextUsage() |
folded from the log: latest request/context window + latest assistant/message usage; usage before a compact/start is stale (pi's "null right after compaction") |
adapt |
ctx.getSystemPrompt / pi.getAllTools / getActiveTools |
the latest request's assembled request/header — the exact text/tool set the model saw |
adapt |
pi.getCommands |
ctx.commands.list(agent); source/sourceInfo omitted — dsh descriptors carry no pi provenance |
adapt |
pi.exec() |
ctx.shell ShellExecutor resolve() → run(); pi's argv words are shell-quoted, since dsh takes a shell string where pi never uses a shell |
adapt |
pi.sendUserMessage() |
agent.followup(), or agent.steer() for deliverAs: 'steer'. NOT inject(): dsh documents that as appending context without running the model, while pi's contract always opens a turn |
adapt |
ctx.ui.notify / setStatus / setWidget / setFooter |
logged; strictUi promotes these to unsupported too |
degrade |
ctx.ui.theme |
plain-text Theme — identity styling, correct for a host with no ANSI surface |
direct |
ctx.ui.confirm / select / input |
dsh's ctx.userQuestions service (@deepseek-ai/dsh-user-questions): when the host mounted a UI provider the adapter asks the real human (confirm → Yes/No question, select → option list, input → free-text form; a cancelled/skipped ask maps to pi's undefined/false, which is a real outcome, not a fabricated choice). The calling agent and the active call's abort signal are passed through, so dsh's live-caller boundary (CALLER_NOT_LIVE/DELEGATED_CALLER — an owned child agent has no human to ask) and cancellation apply. When the host is headless — no service, or the service answers NO_PROVIDER — the ask resolves to pi's cancel convention (undefined/false) with a loud log, instead of inventing the answer; strictUi: true raises instead |
adapt-interactive |
ctx.hasUI |
live probe of the ctx.userQuestions SERVICE: it reports that the service is mounted, not that a UI provider sits behind it — with no provider registered the interactive seams above answer no-selection (loudly logged) rather than asking a human |
adapt |
ctx.ui.editor / custom / onTerminalInput |
no dsh vocabulary exists for these richer interaction shapes | unsupported |
ctx.isProjectTrusted, ctx.sessionManager.getSessionFile, ctx.modelRegistry.*, pi.getThinkingLevel |
no truthful dsh answer | unsupported |
setSessionName / getSessionName (dsh HAS ctx.sessionTitle, but the service would not register in any host wiring reachable from this repo's harness, so the bridge is unproven and therefore unclaimed) / setModel / setThinkingLevel / registerProvider / registerShortcut / register*Renderer |
— | degrade (loud) |
pi.appendEntry / setLabel + ctx.sessionManager.getEntries / getBranch / getEntry / getLeafEntry / getLeafId / getLabel / getHeader |
the dsh session log — pi/entry and pi/label plugin-owned events, projected back through SessionProjection. See that module for where the two models diverge (linear log, so getBranch() ≡ getEntries()) |
adapt |
unmapped pi events (model_select, message_*, tool_execution_*, before_provider_*, …) |
— | degrade (loud at subscribe time) |
Usage
Install via a plugin manager
The published lib/ bundle is self-contained (jiti/schemastery inlined; only
cordis stays external, so the host instance keeps service identity) and the
repo carries a dsh.plugin.json manifest, so dsh plugin managers can
install it directly from git:
dshx install https://github.com/cyzlmh/dsh-pi-adapter.git # marisa (dir|tgz|git-url)
# plugin-registry (dir / tarball):
git clone https://github.com/cyzlmh/dsh-pi-adapter.git && dsh plugin install ./dsh-pi-adapter
Build from source
pnpm install
pnpm build # tsdown bundle -> lib/index.js + tsc types -> lib/types/ (lib/ is checked in for manager installs)
pnpm typecheck
pnpm test # hermetic fixtures + real dsh registry integration (self-skips without a sibling dsh checkout)
PI_ADAPTER_REAL_EXT=1 pnpm test # opt-in: also load real extensions from ~/.pi/agent/extensions
The integration suite imports the built dsh packages from a sibling npm
installation of @deepseek-ai/dsh (../dsh-npm by default, override with
DSH_REPO=/path/to/dsh-npm-installation) and runs registrations, tool
execution, and the pre-execute waterfall veto against the real
ToolRuntime / CommandRuntime.
Mount as a cordis plugin in a dsh cordis.yml (verified end-to-end with the
headless-agent example — a live DeepSeek request called a bridged pi_echo
tool and a real ~/.pi todo.ts extension):
# Plugin specifiers resolve against this file's location, so a relative path
# to the BUILT adapter entry works without publishing the package.
# NOTE the asymmetry: `extensions`/`scanDirs` entries below resolve against
# the HOST PROCESS cwd, not this file — prefer absolute paths in overlays.
- id: pi-adapter
name: ../../../dsh-pi-adapter/lib/index.js
config:
extensions:
- ../dsh-pi-adapter/test/fixtures # resolved against process cwd
includePiHome: false # set true to also scan ~/.pi/agent/extensions
toolPrefix: 'pi_' # avoid collisions with built-in tools
strictUi: false
onUnsupported: disable # 'fail' refuses to start unless every extension fits
Run it (from the dsh repo root, needs DEEPSEEK_API_KEY via $DSH_HOME/.env):
node --import tsx packages/examples/cli-demo/src/bin.ts \
--config examples/headless-agent/your-overlay.yml \
"Use the pi_echo tool with text 'hello dsh' and report what it returned."
Layout
src/
index.ts # cordis plugin entry (apply + Config schema)
compat/ExtensionApi.ts# ExtensionAPI bridge — full interface surface
loader/jiti-loader.ts # jiti loading + pi-runtime alias resolution
test/
fixtures/ # hermetic pi extensions (import-free factories)
fixtures.test.ts # loader tests (hermetic + opt-in real ~/.pi loading)
bridge.test.ts # unit tests against a contract-fake ctx (mirrors dsh's
# register() output validation, waterfall veto, frozen args)
integration.test.ts # ground truth against the real built dsh registry
artifact.test.ts # artifact plane: the published lib/index.js under plain
# Node (via scripts/artifact-smoke.mjs), guarding the
# bundled jiti's runtime babel.cjs
demo/ # live E2E in a real dsh TUI session — see demo/README.md
scripts/
copy-babel.mjs # build step: ship jiti's dist/babel.cjs at package root
artifact-smoke.mjs # plain-Node smoke used by test/artifact.test.ts
Status
Working bridge for the core seams — tool/command registration and execution,
tool_call/tool_result waterfalls, session/turn/agent lifecycle events,
exec(), and loud degradation for everything else — verified against the real
built dsh registry AND end-to-end through the dsh headless-agent example with
live model calls (fixture pi_echo and a real ~/.pi todo.ts extension
both executed). Remaining gaps: per-prompt agent_start (dsh has no exact
equivalent), ctx.ui.editor/custom and other rich interaction shapes,
provider/model bridging, pi's custom
session-entry persistence, and event.input argument rewriting in tool_call
(dsh pre-execute decisions cannot rewrite arguments).
No comments yet. Be the first to write one.