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
namein the patch points at a local file, it must be written as afile:///URL, otherwise the loader reportsERR_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),
nameresolves 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: running → stopping → 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:
- Manifest:
dsh.bundle.patchinpackage.jsonpoints atcordis.patch.yml— the config layer the bundle contributes. Profiles apply bundle layers indsh.profile.bundlesorder, then user patch layers on top. - Entry:
src/index.jsis the plugin module;maininpackage.jsonpoints 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 readyapply(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)
- Tool definitions:
src/tools/index.jsdeclares parameter JSON Schemas, canonical outputs and model-facingrenderwithdefineToolfrom@deepseek-ai/dsh-tools; the schemas flow into system prompt assembly automatically. - 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 idbg_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 codebg_send(id, data: "rs\\n")to trigger a restart; at wrap-upbg_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
pipebackend has no TTY semantics: line-buffered program output may lag and interactive TUIs are unusable; for production install node-pty (pty/autobackend). - 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
stopwaits at most 2 more seconds on extremely stubborn processes, then marks themforcedand 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 internalAttachConsole failednoise when creating a ConPTY session (start/stop); it does not affect functionality.
License
MIT — see LICENSE.
Links
More in this category
superdesigndev/treg★ 425
Tool catalog for agents: search ~2,600 external endpoints (SEO and SERP, backlinks, social, people and company enrichment, ad libraries, scraping) by the task you want done, read each one's parameters and per-call price, then call it with the credential injected server-side. Ships the skill plus an MCP row that stays disabled until TREG_TOKEN is set.
Lum1104/dsh-browser★ 198
Chrome sidebar extension that lets DSH operate your browser directly, no vision capabilities required.
zhaoolee/notes★ 142
Export DSH conversations as Smartisan Notes-style PNGs, or create and update Markdown notes in a configured account-scoped workspace.
liustack/modsearch★ 111
Web search bridge for text-only agents: ask the web or X, get structured JSON evidence (search, fetch, citations).
taxueseek/argo★ 91
Search built for agents: multilingual coverage across web, academic, code, shopping, finance, news, and encyclopedias.
Vladimir-Human/ru-marketplace-mcp#dsh★ 63
Skills and optional MCP rows for ten Russian marketplaces: price comparison across Wildberries, Detsky Mir and Yandex Market, plus per-source search, product cards and reviews. The 13 skills load on install; both MCP rows stay disabled until RU_MARKETPLACE_MCP_DIR points at a local clone, which needs Python 3.12+ and uv.