# bgjobs — standalone background jobs for DSH **English** · [中文](README.md) [![npm version](https://img.shields.io/npm/v/bgjobs)](https://www.npmjs.com/package/bgjobs) [![GitHub tag](https://img.shields.io/github/v/tag/bitsmug/dsh-bgjobs)](https://github.com/bitsmug/dsh-bgjobs/tags) [![License](https://img.shields.io/npm/l/bgjobs)](LICENSE) [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) **Run commands outside the DSH process**: jobs are handed to the Windows Task Scheduler service, so closing DSH or the web page does not stop them. A toast appears in the web UI when a job finishes; live output is always one refresh away; and when DSH is offline you can still manage jobs with the standalone CLI/GUI. Built for long-running work — large downloads, batch scripts, compilation, data sync/export. Submit and walk away; check back anytime. ## Feature overview | Capability | Description | |---|---| | Runs outside DSH | Jobs are hosted via `schtasks`; DSH crashes/shutdowns don't matter | | Live output panel | Floating panel (bottom-right) refreshes every second: draggable, minimizable to a bubble, collapsible to a job list, theme-aware; grouped by workspace, resizable | | Manual cleanup | 🧹 click the cleanup icon to open the trash bar: drag a single finished job to delete, or bulk-clean (>24h only / all; follows the view filter) | | Completion notice | Toast on exit (does not interrupt the session); optionally notify the creating agent (`notify` param) | | Reconnect & track | Auto-recovers after a DSH restart; old job ids can still be queried from disk | | Offline management | CLI / GUI that don't need DSH: list / status / log / submit / kill / cleanup | | Optional sandbox | `bgjob_submit_pwsh` optional `sandbox` constrains job file permissions to no more than the current session mode | | MCP tool calls | `bgjob_submit_mcp` submits one MCP tool call as a background job (**settings-page switch, off by default**); register servers by hand or import them from the DSH config; each server has a "Pre-warm / Cold start / Disabled" three-state control (disabled servers refuse agent calls) | | No residue | A finished job removes its own scheduled task; done jobs stay visible until you clean them up | ## Install / uninstall Prereqs: DSH (`@deepseek-ai/dsh`), PowerShell 7, and Node.js (≥22 for docs below; package requires ^22.19.0 or >=24), Windows. (Verified on DSH `0.1.2-rc.1` ~ `0.1.5-rc.2` · Windows 10 · PowerShell 7 · Node.js 24). > The MCP engine (`bgjob_submit_mcp`) runs on the plugin's own Node dependencies: `@modelcontextprotocol/sdk` and `yaml` ship with the package and are installed by `dsh plugin add`; a local source checkout needs one `pnpm install`. DSH's bundled Node is enough — nothing else to install. **Method A (recommended, npm release)** ```sh $pf="web"; dsh plugin --profile $pf add bgjobs || dsh plugin --profile $pf approve-builds koffi; dsh plugin --profile $pf add bgjobs && Write-Host "✓ bgjobs installed successfully!" -ForegroundColor Green ``` > Replace `web` with your own profile name and paste the whole line into PowerShell (pwsh). The first `add` raises `ERR_PNPM_IGNORED_BUILDS` (koffi build script not approved); `||` then automatically runs `approve-builds` to approve and build koffi, the second `add` succeeds, and "bgjobs installed" is printed. **Method B (from GitHub, always latest)** ```sh $pf="web"; dsh plugin --profile $pf add github:bitsmug/dsh-bgjobs || dsh plugin --profile $pf approve-builds koffi; dsh plugin --profile $pf add github:bitsmug/bgjobs && Write-Host "✓ bgjobs installed successfully!" -ForegroundColor Green ``` > Pulls the default branch directly from the GitHub repo — **always the newest code** (published and unpublished alike), no registry-lag. Both methods install under the name `bgjobs`, so the uninstall command is the same. **From dsh-market install fails with `ERR_PNPM_IGNORED_BUILDS`?** The plugin depends on the native library `koffi`; installing it triggers a build script, and pnpm ≥10 blocks dependency build scripts by default (a GitHub install also runs `prepare`). The error looks like: ``` [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: koffi@3.2.1 dsh: pnpm failed in profile directory \profiles\ ``` Fix (one-time fix): ```sh $pf="web"; dsh plugin --profile $pf approve-builds koffi; dsh plugin --profile $pf add bgjobs ``` The first command approves and runs koffi's build script; the second `add` then succeeds. If your DSH version has no `approve-builds` subcommand, edit `pnpm-workspace.yaml` (the error prints its full path) manually: the failed `add` left a placeholder `set this to true or false` — change it to `true`, then re-run `add`: ```yaml allowBuilds: koffi: true ``` Only the first install needs this — once koffi is compiled it stays compiled, and upgrades/reinstalls don't repeat the step. Restart DSH afterwards: the "Background Jobs Monitor" panel appears bottom-right of the web page and the agent gains `bgjob_submit` / `bgjob_submit_pwsh` / `bgjob_submit_mcp` / `bgjob_mcp_tools` / `bgjob_status` / `bgjob_wait` tools (the two MCP tools require turning on the "MCP jobs" switch in Settings first). **Method C (local source)** 1. Put the repo in a local plugin directory (avoid non-ASCII in the path), e.g. `D:\dsh\plugins\bgjobs`; 2. Make DSH's module resolver find it (junction the plugin dir to DSH's `node_modules\bgjobs`, or add the dir to DSH's plugin scan paths); for local dev also run `pnpm install` once inside the plugin dir (sandbox runner deps, below); 3. Append the mount to `\profiles\\cordis.patch.yml`: ```yaml - insert: - id: bgjobs name: bgjobs ``` **Uninstall:** `dsh plugin --profile remove bgjobs` ## Usage (agent tools) - `bgjob_submit(name, command, workdir, [wait], [notify], [notify_mode])` — submit a background job (`command` is **bat** syntax); `wait` = seconds to wait in place after submitting (0/omitted = return immediately; >0 behaves like `bgjob_wait` with no ids — wait for any of the current session's jobs to finish, falling back to the just-submitted job when no session info); - `bgjob_submit_pwsh(name, command, workdir, [wait], [sandbox], [justification], [notify], [notify_mode])` — submit a background job (`command` is **PowerShell** syntax, UTF-8 logs, safe `exit ` semantics); `wait` same as above; - `bgjob_submit_mcp(name, workdir, tool, [arguments], server | server_config, [timeout_seconds], [wait], [notify], [notify_mode])` — submit one **MCP tool call** as a background job (third engine, also `schtasks`-hosted, visible in the panel, wait/notify supported). `server` is a name registered on the settings page, `server_config` is an inline config (`{transport:"stdio",command,args,env,cwd}` or `{transport:"streamable-http",url,headers}`) — give exactly one. The job connects to that server and calls the tool once; the result lands in the log and `/result.json` (`channel` records whether it hit a pre-warmed resident connection or did a cold start). Exit codes: `0` success / `1` tool reported an error / `2` connect or call failed / `3` timeout; `timeout_seconds` **is unlimited by default** (when that server has a `timeoutMs` registered on the settings page, that value applies), or pass any positive number of seconds. **Off by default — turn on the "MCP jobs" switch in Settings first** (calls are refused otherwise). As in DSH, the session access mode (read-only, …) does **not** restrict MCP jobs. Check tool names with `bgjob_mcp_tools` first; - `bgjob_mcp_tools(server | server_config, [refresh])` — list that MCP server's tools (name/description/required fields) to confirm tool names and argument shape before submitting; `refresh: true` bypasses the 10-minute cache. DSH's already-registered `mcp____*` tools are used first (zero startup cost); only on a miss does it actually connect/spawn the server to probe. Also gated by the "MCP jobs" switch; - `bgjob_status(jobId)` — query status / exit code / log tail; for a look at the current state only — do not poll it in a loop (use `bgjob_wait` to wait); - `bgjob_wait(jobId | jobIds, [timeoutSeconds], [logic])` — wait for background job(s) and **return immediately** with exit codes and log tails (`timeoutSeconds` **defaults to the "Default wait timeout" set in Settings** — unlimited when that setting is unset, i.e. it waits until the job finishes; pass `0`/a negative number to force unlimited for this call, or a positive number to get a `timedOut: true` snapshot at that point). Two modes (`logic`): - **`any` (default)**: a single `jobId` waits for that job; a `jobIds` array is **any-race** (returns as soon as one finishes, with the finisher + the rest `pending`) — the **default posture when several jobs run in parallel**: handle whichever lands first and keep waiting for the rest, no need to wait for all; omitting both waits for **any job of the current session** to finish; - **`all` (conjunctive)**: returns `allDone: true` plus each job's exit code/log tail only when **all jobs succeed**; **any failure returns at once** with `failed: true` + `failedJobId` + the finished jobs' `results` + the rest in `pending` (a non-zero exit code, or a cleaned-up/unknown job, both count as failure) — no waiting for the stragglers. Use it only when "everything must succeed before continuing" or when "one failure means stop now"; to make progress as results land, use the default `any`. Omitting `jobIds` waits for all jobs of the current session; - `bgjob_list` — list all jobs submitted by the current agent session (id/status/exit code); used together with the wait tools' default mode. - **Do not poll with sleep**: wait with `bgjob_wait`; don't use `sleep` / `Start-Sleep` / `timeout`, nor a "loop over `bgjob_status`" (it occupies the turn and blocks incoming messages). Just tell the AI (name the workdir and job name, and say whether you want it to wait for the result / notify you): > Run this whole chain in the background — clone the Linux kernel into `D:\work\linux`, then `make -j16` — and notify me when it finishes (`notify: on-exit`); don't let the build tie up the conversation. > Start two background jobs in parallel: one downloading a dataset, one rebuilding; **show me whichever finishes first** and let the other keep running (any-race, no need to wait for all). > Convert the 30 CSVs under `D:\data` to UTF-8 in one batch with the pwsh engine; I need **all of them to succeed before continuing** — stop as soon as any one fails (`logic: 'all'`). > Submit one MCP call as a background job: server `glm`, tool `web_search`, query「latest LLM progress」, and send the result back to this session when it's done. Then: - Job output is streamed live to `\.dsh\bgjobs\\stdout.log`; - On exit, `\.dsh\bgjobs\\exitcode.txt` gets the exit code and a toast pops in the web page; - By default, completion **does not interrupt the session**; when you want the agent to know and wrap up, pass `notify: on-exit` (or `on-completion` success-only / `on-fail` failure-only), plus optional `notify_mode` (`wakeup` wake an idle session / `quiet` inbox-only / `always`). - **Delivery marker (notify view)**: each job records whether its result has been delivered into the session context — a completion notice that was injected (`notified·notify`) or a `bgjob_wait` that returned it (`notified·wait`). `bgjob_pending_list` lists the session's **not-yet-delivered** jobs (the notify view), and the default mode of `bgjob_wait` (including `logic: 'all'`) waits only on that view, so an already-delivered result is never returned twice. The web panel and the offline GUI both show a "notified / pending" marker. ## Web panel Top bar, left to right: cleanup (opens the bottom trash bar: drag a finished job to delete, or bulk-clean >24h / all), collapse (to a compact job list), minimize (floating bubble anchored at the button). Toolbar toggles: "Only this session" (show only the current session's workspace jobs) and "Full access" (pre-approve full-access jobs; off by default). Click a job row to expand its live log. Rows show a live "Runtime" by default (growing while running, total time once done); move it to the detail view or hide it under Settings → Background Jobs → Field display. Panel copy follows the DSH UI language (Chinese DSH → Chinese panel, otherwise English). **MCP jobs (its own Settings page, off by default)**: - **MCP jobs switch**: controls whether the agent may use `bgjob_submit_mcp` / `bgjob_mcp_tools` (refused, with a pointer to the switch, while off). Takes effect immediately, no DSH restart. As in DSH, the session access mode (read-only, …) does not restrict MCP jobs; - **MCP servers**: register servers the agent can reference by name (name + JSON config). Each row has three text buttons — **Pre-warm / Cold start / Disabled** (amber / green / grey, so they cannot be mistaken for the global switch) — plus **Edit** (loads that server's full config — including plaintext env/headers — into the form below), "List tools" (expand tool names, click to copy) and "Delete"; the list only returns env/headers **key names**, never values. **Pre-warm** keeps a resident connection inside the DSH process so jobs reuse it instead of paying the cold start every time — valid only while DSH runs; on failure the job falls back to an in-job cold start, so correctness never depends on it (stdio benefits most; http transports spawn nothing, so the gain is small). Only failures that definitely did not execute anything fall back — **a call that was already sent is never re-run when it fails or times out** (no duplicate side effects); such a job ends with exit code `2`/`3` and can be resubmitted (v0.1.84). **Cold start** enables the server without a resident connection. **Disabled** makes `bgjob_submit_mcp` / `bgjob_mcp_tools` refuse that server and drops its resident connection, while "List tools" still works; - **Export / Import**: export as a **DSH YAML** snippet (`@deepseek-ai/dsh-mcp-client` entries, paste into `cordis.patch.yml`) or **bgjobs JSON** (backup/migration, read back by the import box as-is). Import accepts pasted text or a picked file and auto-detects DSH patch snippets / bgjobs JSON / one-or-more server config objects (each needs `serverName`), with "skip / overwrite existing names"; entries containing `!!js` are refused unless you tick the force box (they are never evaluated — fill in env/headers by hand). **Exported text and each job's `mcp.json` contain plaintext secrets — redact before sharing**; - **MCP in DSH (import)**: reads the `@deepseek-ai/dsh-mcp-client` entries already configured in the **active profile** or the global `cordis.patch.yml` and imports them into the bgjobs registry with one click (read-only with respect to the DSH config). The header shows the active profile and how it was detected (command-line `--profile` / module-path realpath / single profile); entries containing `!!js` expressions are **never evaluated** and are skipped by default (fill in env/headers manually). Both the quoted form (`!!js '"Bearer " + process.env.X'`) and the **unquoted** form (`KEY: !!js process.env.X`) are recognised as needing manual review; if an expression sits somewhere it cannot be pinned to a single entry, every entry in that batch is flagged instead of silently importing the JS text as a plain string. ## Offline CLI (works without DSH) ```powershell # run from the tools/ directory .\dsh-bgjobs.ps1 list .\dsh-bgjobs.ps1 status -Id .\dsh-bgjobs.ps1 log -Id [-Tail 100] .\dsh-bgjobs.ps1 submit -Name -Command -Workdir [-Pwsh] .\dsh-bgjobs.ps1 kill -Id [-NoDeleteDir] .\dsh-bgjobs.ps1 cleanup [-OlderThanHours 24] # 0 = clean all .\dsh-bgjobs.ps1 index -Workdir ``` ## GUI Double-click `tools\dsh-bgjobs-gui.bat` to open a standalone window (no DSH needed): job list/log, submit (bat or pwsh), kill, cleanup (custom age cutoff or all), rebuild index. GUI and Toast copy follow the Windows UI language (zh* → Simplified Chinese, otherwise English); the CLI prints English. ## Data & storage - Job data: `\.dsh\bgjobs\\` (`job.json` metadata, `stdout.log` output, `exitcode.txt` exit code; MCP jobs also have `mcp.json` for the call spec and `result.json` for the outcome); - Global state: `$DSH_HOME\bgjobs\index.json` (job "map"), `$DSH_HOME\bgjobs\fullaccess.json` (full-access switch), `$DSH_HOME\bgjobs\ui-prefs.json` (web UI prefs), `$DSH_HOME\bgjobs\mcp-prefs.json` (MCP jobs switch), `$DSH_HOME\bgjobs\mcp-servers.json` (MCP server registry incl. per-server pre-warm/enabled flags), `$DSH_HOME\bgjobs\mcp-tools-cache.json` (tool-list cache, 10 minutes); - `done` jobs persist by default until you clean them (panel 🧹 / CLI cleanup / GUI). ## Notes & limits - `workdir` must be an absolute path inside a DSH workspace; - Jobs run by default "only while the user is logged in": closing DSH/the terminal is fine, but **logging out of Windows terminates jobs**; - Don't put `> log`-style redirects in your command (the plugin already redirects all output and guarantees UTF-8); - **Sandbox**: `sandbox` only constrains file effects (writes outside the workspace/temp area are denied), network is unrestricted; it is "best effort", not a mathematical boundary — it fails if the workdir sits in an Everyone-writable location; sandboxed job dirs get Everyone:read (the script text is visible to local users); bat-engine jobs are always full-access, so restricted sessions must enable "Full access" to submit them; - In a restricted session, requesting more than the session mode triggers an approval prompt — put the reason in `justification`. - **MCP jobs**: off by default (enable in Settings); if a job is force-killed, its stdio MCP server child may linger (a normal finish is cleaned up by the host, which also kills the pid recorded for a job when you delete it); each server has a "Pre-warm / Cold start / Disabled" three-state control in Settings — **Disabled** makes `bgjob_submit_mcp` / `bgjob_mcp_tools` refuse that server and drops its resident connection (while "List tools" still works); the "pre-warm" connection is only valid while DSH runs and never changes the "jobs survive DSH" guarantee; the offline CLI/GUI only view and delete MCP jobs — they do not submit MCP calls. - **A stopped wait is not a failed job**: when you hit stop/interrupt while the agent waits, the wait itself ends **as an error** (the message names each job's current state and says you can wait again). That is DSH's cancellation semantics — once the caller cancels, a successful return cannot reach the model, only an error can; the job keeps running in the background, is not marked delivered, and the agent can call `bgjob_wait` again. A message from another agent during the wait returns normally instead (`stoppedBy: 'message'`): that result carries **no message body**, but the call **declares the current turn finished** — DSH then delivers the message (yours or another agent's) to the agent as a regular user message, queued next-turn prompts included; the agent should not paper over it with more waiting or blocking work. - **MCP timeout cleanup has a 1–2s grace period**: after an MCP timeout/failure the host closes the connection and reaps the server child (the SDK's `close()` waits ~2s before escalating), so `result.json`'s `durationMs` can exceed `timeoutMs` by 1–2s (the same file records `timeoutMs` for comparison). ## Development Architecture, mechanism details, testing and release flow: see [docs/developer.md](docs/developer.md). ## License MIT — see [LICENSE](LICENSE).