DeepSeek Harness Plugin

JohnXu22786/pty-runner

Stars ★ 0 Category Tools & Capabilities Added 2026-08-16

Long-lived background terminal jobs for dsh: launch processes in a PTY, write input (including control sequences like Ctrl+C), page through ring-buffered output, and stop jobs gracefully or forcefully.

Install

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:JohnXu22786/pty-runner

Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time. Only install sources you trust, and pin a commit (github:owner/repo#sha).

README

简体中文

backstage

Background terminal job management plugin that gives DeepSeek Harness (dsh) persistent background process (PTY) management.

When an AI agent runs dev servers, watch tasks or long builds, ordinary one-shot shell calls are not enough: the process is reaped when the call ends, and the agent cannot send interactive input or read later output. This plugin runs such processes in dedicated background terminal sessions (jobs) that the agent can, at any time:

  • Launch a process that keeps running independently (PTY, interactive programs supported)
  • Write input to the process (plain text or control sequences like \x03)
  • Page through process output (ring buffer, regex filtering, resume from a breakpoint)
  • List all jobs by status/group
  • Stop processes gracefully or forcefully, with unified cleanup on plugin unload

Feature overview

Capability Description
Background execution Processes run in their own pseudo-terminal; they do not exit when the tool call ends
Concurrent jobs Manage any number of jobs at once, each with its own buffer and state
Interactive input Write plain text and escape control sequences (Ctrl+C, arrow keys, ESC, ...)
Ring output buffer Each job keeps the most recent N lines (default 5000), terminal escape sequences stripped
Paged reads Resume from a global line number; regex filtering (grep-like) and case-insensitivity
Exit detection Distinguishes normal exit (exit 0), crashes (non-zero exit code/signal) and deliberate termination
Port hints Checks target port occupancy before launch; auto-detects service addresses from output (e.g. http://localhost:5173)
Group management Jobs can be tagged with groups; a whole group can be stopped in one call
Timeout protection Optional timeoutMs; jobs are terminated automatically on timeout
Exit notification Optional: inject a notice into the session when a job ends (no polling)
Cross-platform Windows (ConPTY) / macOS / Linux; falls back to a pipe backend when no native module is available

Installing in DSH

npm i -g @deepseek-ai/dsh        # or npx @deepseek-ai/dsh
dsh plugin --profile demo add github:JohnXu22786/pty-runner
dsh --profile demo               # start

Remove with:

dsh plugin --profile demo remove dsh-backstage

Installation

Requirements: Node.js ≥ 20, npm or pnpm.

Option 1: install as a bundle into a profile (recommended)

From any directory (for example the directory above this plugin), after installing the dsh CLI:

npm i -g @deepseek-ai/dsh        # or npx @deepseek-ai/dsh
dsh plugin --profile demo add /path/to/pty-runner
dsh --profile demo --dump-config # should show the "# == dsh-backstage" layer
dsh --profile demo               # start

dsh plugin add does three things: links this package into the profile's dependencies, appends dsh-backstage to dsh.profile.bundles, and applies the plugin line declared in this package's cordis.patch.yml. Afterwards dsh plugin --profile demo remove dsh-backstage uninstalls it completely.

Option 2: load as an overlay (local development)

This package ships a cordis.patch.yml declaring the plugin line; it can also be mounted onto any profile directly with --patch:

# POSIX
dsh web --patch /path/to/pty-runner/cordis.patch.yml

Windows note: if name in the patch points at a local file, it must be written as a file:/// URL, otherwise the loader reports ERR_UNSUPPORTED_ESM_URL_SCHEME. Example:

- insert:
    - id: backstage
      name: 'file:///D:/path/to/pty-runner/src/index.js'

When installed as a bundle (Option 1), name resolves to the package name and there is no such restriction.

Load succeeds when the log shows [backstage] ready: backend=auto bufferCapacity=5000 ....

Configuration

The plugin accepts optional configuration (in the config block of the patch line or a profile patch layer); everything has a default, and illegal values fail plugin loading rather than running half-configured:

- insert:
    - id: backstage
      name: dsh-backstage
      config:
        bufferCapacity: 20000   # output lines kept per job (100-200000, default 5000)
        defaultGroup: 'default' # default group tag when none is specified
        backend: 'auto'         # 'pty' (real pseudo-terminal) | 'pipe' (pipe fallback) | 'auto'
        stopGraceMs: 1500       # grace period for graceful stop, then force kill (50-60000)
        portScan: true          # check requested port occupancy before launch

backend semantics: pty uses node-pty (ConPTY on Windows) and interactive programs behave exactly as on a real terminal; pipe uses a plain subprocess pipe without terminal semantics (programs that only flush when a TTY is present may buffer output), with zero native dependencies; auto prefers pty and degrades to pipe when no native module is available.

Tool interface

The plugin exposes 7 tools to the model (all prefixed bg_ to avoid collisions with built-in tool families):

Tool Purpose
bg_launch Launch a background job (command, args, cwd, env, title, group, timeout, port pre-check, exit notification)
bg_send Write input to a job's terminal (escape control sequences supported)
bg_read Page through the output buffer (resume from, limit, regex pattern, case-insensitivity)
bg_list List all jobs or filter by group/status (PID, status, output stats, detected service addresses)
bg_stop Stop one job (graceful → force kill; drop deletes the record)
bg_stop_group Stop all jobs in a group
bg_drop Clear records of finished jobs (by id or by group)

Input escapes

bg_send's data supports backslash escapes, decoded to real bytes before writing:

Escape Meaning Escape Meaning
\xHH Hex byte (e.g. \x03 = Ctrl+C) \cX Control character (\cC = Ctrl+C, \c? = DEL)
\n \r \t newline / CR / tab \e \a \0 \b ESC / BEL / NUL / backspace
\\ literal backslash \uXXXX Unicode character

Unknown escapes (e.g. \q) are kept as-is and never silently dropped. Under the Windows pty (ConPTY) backend, a bare \n is converted to the \r required by ConPTY line mode (\r\n stays untouched); the pipe backend writes raw bytes.

Exit status semantics

Each job's status moves through: runningstopping → terminal finished (exit 0) | failed (non-zero exit code or signal, i.e. crash detection) | stopped (terminated by stop/timeout/plugin unload; reason distinguishes user / timeout / shutdown / forced).

Output buffer semantics

The lines returned by bg_read carry a global line number index; next in the response is the starting point for the next page, and truncated / dropped hint whether history was cut because of the capacity limit. An unterminated trailing line (process still writing) participates in reads as the latest line.

Events and the service interface

The plugin provides a service under the same name via ctx.provide('backstage', api); other plugins can inject: ['backstage'] to drive jobs programmatically, or subscribe to events:

export const inject = ['tools', 'backstage']
export function apply(ctx) {
  // subscribe to job exit events (returns a disposer; auto-unsubscribed on plugin unload)
  ctx.backstage.onExit(({ id, status, exitCode, reason }) => { /* ... */ })
  // other methods: launch / stop / stopGroup / read / write / list / stats / drop ...
}

Events: exit (job ended; payload carries id/status/exitCode/reason/info), created (new job), data (output available), dropped (record cleared) — mirrored on the service surface by the onExit / onCreated / onData / onDropped subscription helpers.

Exit notification

When bg_launch is called with notifyOnExit: true, the plugin injects a notice context into the session via exec.agent.inject() as soon as the job ends (job status, exit code and a reading hint); the next model request sees it — no polling needed. Failed injections (e.g. agent already destroyed) are silently swallowed and do not affect job state settlement.

How the harness loads this plugin

This plugin follows the dsh (Cordis) plugin contract; it is a standard "bundle-type" plugin package:

  1. Manifest: dsh.bundle.patch in package.json points at cordis.patch.yml — the config layer the bundle contributes. Profiles apply bundle layers in dsh.profile.bundles order, then user patch layers on top.
  2. Entry: src/index.js is the plugin module; main in package.json points at it. The module declares plugin metadata via named exports, which the harness loader (Cordis) resolves and invokes:
    • name — plugin name (backstage)
    • inject: ['tools'] — required harness services; the plugin loads only after all are ready
    • apply(ctx, config) — registers capabilities: ctx.tools.register(defineTool(...)) registers the 7 tools (registration is tied to the plugin lifecycle and auto-unregistered on unload); ctx.provide('backstage', api) provides the service; ctx.effect(...) registers unload cleanup (Cordis awaits that disposer's Promise, so unload stops every job before finishing)
  3. Tool definitions: src/tools/index.js declares parameter JSON Schemas, canonical outputs and model-facing render with defineTool from @deepseek-ai/dsh-tools; the schemas flow into system prompt assembly automatically.
  4. Unload: when the plugin line is removed or the harness shuts down, Cordis runs all effects in reverse order — registered tools, the service and job processes are cleaned up, leaving no background orphans.

Language-agnostically: a dsh plugin = a module exporting apply(ctx, config) + a line declaring it in a patch layer. This plugin's cordis.patch.yml is that line; dsh.bundle in package.json tells the installer which layer it contributes.

Examples

Command-line smoke test (no dsh needed)

npm install
node examples/mini-harness.js   # loads the plugin with the bundled mini harness and runs launch → read → write → stop
npm test                        # 95 unit and integration tests (node --test)

examples/mini-harness.js doubles as the smallest readable example of "how a plugin-based harness loads it": it simulates ctx.tools.register / ctx.provide / ctx.effect and calls apply per the real contract.

Conversation use case

User: run the vite dev server in the background, and make sure port 5173 is free. agent: bg_launch(command: "npm", args: ["run", "dev"], title: "vite", ports: [5173], notifyOnExit: true) → returns job id bg_xxx, status running, empty warnings (port is free). Later, when the agent needs it: bg_read(id, limit: 50) for the latest log; bg_send(id, data: "\\x03") to interrupt; after editing code bg_send(id, data: "rs\\n") to trigger a restart; at wrap-up bg_stop(id, drop: true).

Directory structure

pty-runner/
├── package.json          # package metadata + dsh.bundle manifest
├── cordis.patch.yml      # bundle config layer: declares the plugin line
├── src/
│   ├── index.js          # plugin entry: name / inject / apply
│   ├── core/             # harness-agnostic core (independently testable)
│   │   ├── registry.js   # job registry and lifecycle state machine
│   │   ├── launcher.js   # pty / pipe dual-backend process launcher
│   │   ├── history.js    # line-level ring buffer and paged reads
│   │   ├── ansi.js       # stateful ANSI escape sequence stripping
│   │   ├── escape.js     # input escape decoding and echo description
│   │   ├── portprobe.js  # port occupancy probing and service address detection
│   │   ├── config.js     # config defaults and validation
│   │   └── index.js      # core export aggregation (package exports "./core")
│   ├── tools/index.js    # 7 defineTool definitions
│   └── host/service.js   # backstage service surface
├── examples/mini-harness.js
└── test/                 # node --test tests

Known limitations

  • The pipe backend has no TTY semantics: line-buffered program output may lag and interactive TUIs are unusable; for production install node-pty (pty/auto backend).
  • Process output is treated as text (UTF-8 decoding); no fidelity promise for binary output. Extremely long unterminated lines (beyond 65536 characters, ~64 KiB ASCII) keep their tail and count toward dropped, to keep memory bounded.
  • The force-kill fallback of stop waits at most 2 more seconds on extremely stubborn processes, then marks them forced and lets the system reap them.
  • On Windows, node-pty's kill() does not accept signal names (it throws); the plugin handles this per-platform. Some Windows environments print node-pty's internal AttachConsole failed noise when creating a ConPTY session (start/stop); it does not affect functionality.

License

MIT — see LICENSE.

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →