cyzlmh / dsh-pi-adapter

已收录

Run pi coding-agent extensions (ExtensionAPI) inside DeepSeek Harness via a cordis plugin bridge

main其他 查看源代码

安装

pnpm test # hermetic fixtures + real dsh registry integration (self-skips without a sibling dsh checkout)

此命令根据 GitHub 仓库地址生成。运行前请检查上游 README 与源代码;需要可复现安装时,请固定 release 或 commit。

README

维护者编写的文档快照。

在 GitHub 查看 ↗
提交版本 58b7aed同步于 2026年8月18日

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 / typebox from the global pi install (npm root -g), since those packages only exist there.
  • ExtensionBridge implements the full ExtensionAPI surface. 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 — raises UnsupportedSeamError

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

wherewhat happens
its factory (mount)partial registrations unwound, extension not mounted
a tool_call gatethe call is denied, permanently — a gate that cannot run never becomes one that permits
a tool_result handlerthe tool's own result passes through unchanged
a tool body / commandthat invocation fails with the seam named
any other handlerlogged; 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 APIdsh seamtier
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 bridgeadapt
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 / getFlagin-process registrydirect
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 throwingadapt
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/endadapt
pi.on("session_start/shutdown")session/created / session/disposeddirect
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 sessionadapt
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 degradesadapt
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 inventedadapt
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 buscordis pi-ext/* namespaced events (and emit is real, not a silent no-op)adapt
ctx.isIdlelive agent.status readdirect
ctx.hasPendingMessagesinbox depth tracked through agent/inbox/* lifecycle eventsadapt
ctx.abort()agent.cancel({kind:'user'}) — pi's abort is a human interruptadapt
ctx.compact()ctx.compact.compactNow() with the agent's reserveTurnAdmission(); fire-and-forget, failed attempts stay durable in the logadapt
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 / getActiveToolsthe latest request's assembled request/header — the exact text/tool set the model sawadapt
pi.getCommandsctx.commands.list(agent); source/sourceInfo omitted — dsh descriptors carry no pi provenanceadapt
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 shelladapt
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 turnadapt
ctx.ui.notify / setStatus / setWidget / setFooterlogged; strictUi promotes these to unsupported toodegrade
ctx.ui.themeplain-text Theme — identity styling, correct for a host with no ANSI surfacedirect
ctx.ui.confirm / select / inputdsh'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 insteadadapt-interactive
ctx.hasUIlive 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 humanadapt
ctx.ui.editor / custom / onTerminalInputno dsh vocabulary exists for these richer interaction shapesunsupported
ctx.isProjectTrusted, ctx.sessionManager.getSessionFile, ctx.modelRegistry.*, pi.getThinkingLevelno truthful dsh answerunsupported
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*Rendererdegrade (loud)
pi.appendEntry / setLabel + ctx.sessionManager.getEntries / getBranch / getEntry / getLeafEntry / getLeafId / getLabel / getHeaderthe 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).

项目文件与信号

以下项目是目录快照中检测到的公开仓库信号。

测试已检测
示例已检测

仓库信息

开发语言
JavaScript
许可证
MIT
最后更新
2026年8月14日 03:20

谨慎安装

请检查源代码、权限、生命周期脚本、依赖与网络访问;不受信任的插件应先在隔离环境中测试。