PluginWorld
Ds

dsh-pause-resume

dsh✓ SPEC VERIFIED

One header button stops running turns, goals and jobs, writes a plan to disk, and restores that work after a restart or crash; the button shape is the only status.

@regrevia · v0.8.2 · MIT · updated today

SECURITY

A

SCORE

75

INSTALLS

▲ 5.2K

PLUG IN

npx @deepseek-ai/dsh web

Launch, then search "dsh-pause-resume" in the built-in market to plug it in

README

dsh-pause-resume

One button in the DeepSeek Harness session header. It stops what is running, writes a small plan to disk, and after a restart the same button puts that work back.

  • ⏸ — work is running. Press to stop it.
  • ▶ — work is stopped, or a previous process left work to restore. Press to restore it.
  • ↻ — a turn stopped on its own. Press to continue what did not finish.
  • ⏸ dimmed — nothing to stop, restore or continue.

The glyph is the only status. There is no counter, no panel, no badge and no toast, because a second indicator would be a second source of truth for the same fact. When the last press left something for a person to look at, the one-sentence tooltip says so and the button still offers the same glyph.

What it restores

Work Pause Restore
A session with an unfinished turn turn cancelled, session recorded session mounted, then one visible continuation message
A goal that was armed paused with the revision it had goals.resume with that exact revision; the goal driver continues on its own
A session that only carried that goal recorded, nothing stopped mounted; the resumed goal takes over
A running background job killed and recorded recorded only: a job is a live process, not replayable, so the continuation message names it and the turn restarts it if the work still needs it
A session that ended normally untouched untouched, and never recorded in the first place
A terminal / finished session untouched untouched
A schedule untouched untouched

Schedules are not paused: the schedule service has no disable operation, only delete, and deleting a person's schedule to pause it is not a trade this plugin makes. A schedule that fires while paused starts a turn, but the button keeps showing ▶ while a pause plan waits: a pending plan owns the main action, so the only honest next step is to finish the restore. The schedule appears in that restore summary like any other work started after the press.

How resume works

A restore is not a claim that work continued. It is three honest steps per item:

  1. Mount. A stopped session is mounted through the Harness resume path, which preserves its agent preset. If it is already mounted, that step is skipped.
  2. Continue, only where the press actually cancelled a turn. The continuation is one ordinary user message sent through sessionController.prompt, with a request id derived from the plan and the session. The id makes a repeated restore idempotent, and the message is visible in the transcript, so the person can see exactly what was resumed.
  3. Re-arm a goal with the revision it had when paused. The revision is the person's veto: if the goal changed while paused, it is not ours to resume and the item is skipped.

Every attempt is journaled with the id of the process that made it, but a journal is reporting, not a promise: the next boot asks the live Harness again instead of trusting an old row. The continuation request id is derived from the plan and the session, so a retry before a turn starts is deduplicated by the Host. A failure leaves the plan on disk and the next press retries every item that still needs work.

A subagent session cannot be mounted by this process: its parent chat owns the routing. That row is reported once as attention for the parent, not retried as a failure, so it never keeps the plan open.

Crash, not just a press

A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead: running.json holds the sessions whose turn is open right now and the id of the process that wrote it. The previous process's record is turned into a crash plan in pending.json before the heartbeat file is replaced, so a second or third restart still sees the same work and offers ▶ with no press needed.

A crash record is weaker than a press: it proves a session existed, not that a turn was open after the restart. Restore attaches every recorded session and reads the live turn. A row whose turn is still running is continued; a row that is idle is left idle by the probe, which mounts it to read it and does not prompt it. A row whose state cannot be read or proven is reported as unknown, and unknown is not finished: the plan stays on disk, the button keeps offering ▶, and the next press tries again. A recorded goal is restored whether or not its session's turn is provable, because dropping it was the path that ended a crashed set with every goal in attention and none resumed. Sessions that ended normally never enter the file.

The same process reloading the plugin (hot reload) sees its own id and knows there is nothing to restore. A pausing plan found on disk means the press itself was interrupted: those sessions are left alone, and the button keeps offering the restore until a person presses it.

Stops that were not a press

A turn can end without a person pressing anything: the provider returns an error after its own retries, or the model hits its output limit. Those turns are the one case a restart cannot fix on its own, so the plugin keeps a separate small ledger, continue.json, and offers ↻ instead of ▶. The ledger is independent of the pause plan: it never turns into a crash plan, never pauses or cancels anything, and never sends a message by itself.

What becomes a candidate:

  • A session's latest turn ended error or max-tokens, and no later turn started or ended.
  • The session is a root session with no parent and is not a subagent. A child's stop is answered by its parent's tool result; continuing the child alone would speak into a conversation nobody reads.
  • The turn did not end completed, aborted, interrupted or blocked. A manual pause aborts its turn, so the stop a person asked for never appears here; a block before admission is work that never ran and is left alone.

The press continues the rows visible at that moment, two at a time, with an ordinary user message. It is not a retry loop: a row that fails stays in the ledger for the next press, and the button returns to ↻ when anything is still waiting. A row disappears when a newer turn starts, when the session is already open, when the goal is active, when the session is gone, or when the continued turn completes. Title comes from the session's own title event; nothing is loaded just to name a row. The batch is capped at 40 rows, and a row keeps its attempt number across failed presses.

Each row is labelled for display only: blocking when it already needs a person or its error code is one a retry cannot fix (auth, permission, quota, unknown model, context length), actionable otherwise. pendingContinue carries both counts next to count, so failed work stays visible while a batch is running; the label never hides a row and never changes what a press does — one press still continues every visible row.

Starting a new message in a session that has a waiting row is a takeover: it clears that row without claiming anything about the old work. Work continues only after a press, never on plugin load, never on a timer, and never because the previous batch failed.

Progressive restore (off by default)

Six long sessions restored at once are six upstream model requests at once, and a gateway that is happy with one slow stream is often unhappy with six. For Direct API users this is usually fine and the extra waiting is pure cost, so progressive restore is off by default: restore follows the ordinary path with no added spacing, no request pool and no cooldown timer installed at all.

Turn it on when batch restores run into rate limits or timeouts: a shared account or gateway quota, a self-hosted proxy with limited capacity, or an environment where restoring several sessions at once is what triggers the failures. Single-session timeouts are not helped by it; those need investigation of the request and the upstream.

Two layers, because either alone is enough to defeat the other:

  • Dispatch spacing. Sessions are restored in waves by lineage depth, with a gap of restoreStartGapMs (3 s) plus up to 0.4 s jitter between starts.
  • A real request pool. The plugin registers an llm/stream middleware and holds one permit per adapter request until that stream ends, fails or is cancelled — not when the turn opens, not when the first chunk arrives, and not when a tool or subagent wait begins (that request is over). Compaction enters the same pool with purpose: 'compaction', and every official retry takes a fresh permit. The guard is open only while a restore runs, so ordinary chatting is never queued behind it.

The pool starts at restoreCapacity (1). Two clean streams in a row raise it by one, up to restoreMaxCapacity (2). Two failures in a row drop it back to one and close the pool for restoreCooldownMs (40 s), so one unwell upstream cannot be hammered by a queue of sessions. An abandoned stream (the person paused) is neither a success nor a strike: cancelling is cheap.

The switch is read when you press restore; a running batch keeps the setting it started with, so turning the feature off mid-batch cannot suddenly release the queue. Press pause to stop the current batch.

Nothing about reliability depends on this switch. Pause and restore of sessions, subagents and background jobs, pausing again during a restore, plan persistence, crash recovery, continuation deduplication and the retry entry all still apply with progressive restore off. The restore loop keeps its own small worker waves either way; only the extra limit on real model requests is optional.

Pausing while a restore is running is immediate. The press stops dispatching, refuses every request still waiting for a permit, asks the Harness to stop the work that already started, writes what is left back to the plan as an outstanding continuation, and returns paused with interrupted: true. The next press resumes the same plan; the continuation request id is per cycle, so a message already admitted is deduplicated instead of sent twice.

/state reports the setting and the queue while a plan exists: {progressive, active, enabled, capacity, running, queued, dispatched, failed, interrupted, cooldownUntil} under restore. The button itself stays icon-only.

Configuration (all optional):

Key Default Meaning
restoreProgressive false opt-in staged restore; off keeps the plain path
restoreCapacity 1 model requests allowed when a restore starts
restoreMaxCapacity 2 ceiling the health gate may raise the limit to
restoreCooldownMs 40000 pause after two consecutive failures across sessions
restoreStartGapMs 3000 spacing between restore starts, plus jitter

Scope, stated honestly: this governs requests that pass through this Host's model stack. The Harness's own restore flow and a subagent that reaches a provider outside this Host are outside the pool, and no plugin can promise a concurrency limit for a company gateway it does not own. The 300 s upstream idle timeout is an upstream decision; progressive restore cannot pretend to fix it, but it makes the restore produce evidence (one session, one exact request) instead of an avalanche.

Restore pacing and heap headroom

Restoring a session deserialises its whole transcript into the Host heap. One large session is GB-scale on its own, and the Host's V8 has roughly a 4 GB ceiling; past it the process does not throw, it dies with FATAL ERROR: MarkCompactCollector: young object promotion failed. Measured twice on 2026-10-29: pid 91126 after 64 minutes at 3.65 GB, and pid 61989 fifteen minutes after restoring 15 sessions at 3.58 GB. Both left crash-*-host.log under ~/Library/Logs/DeepSeek Harness/.

So a restore is paced by what it can afford to hold, not only by what it has to do:

  • Only what this crash interrupted. Sessions recorded by the crash plan are restored; history and sessions that ended normally are never touched. That is unchanged since 0.6.x.
  • Smallest first. The queue is ordered by transcript size, so light sessions go first and heavy ones last. Light sessions run restoreConcurrency (2) at a time.
  • Heavy sessions run alone. A session whose compressed transcript is above restoreHeavyMB gets a step of its own; the next one starts when its turn ends, or after restoreSettleMs (90 s) as the upper bound.
  • Heap gate. Before each step the restore reads v8.getHeapStatistics(). If the headroom left is below restoreHeapReserveMB, the round mounts nothing further. A session that did not get its turn is recorded in deferred and left unstarted — loading its transcript is exactly the cost the gate exists to avoid — and the next press or the next boot picks it up.
  • Predicted before mounted. Admission also estimates the heap a transcript will take (six times its compressed size) and holds the row back when the estimate does not fit the headroom that is left, so a huge session is refused before it is read rather than after it has grown the heap. A session whose size cannot be read is treated as heavy and goes through the serial heavy path; /state.scheduling.unknownWeights reports how many this round could not measure.

A round that held rows back does not consume the plan: it ends partial, keeps every row on disk with its deferred note, and /state.restore.deferred lists what it could not mount. Consuming it would have deleted the only record of work that had never started, and the next boot would offer a clean restore while a large session was still owed.

When a round restored a heavy session or produced deferred, /state also carries restartRecommended with the heap usage and the reason, and the client's tooltip switches to "重启后继续没放下的内容" once rows are waiting on a restart. The plugin never exits or restarts the Host itself: a process is a person's running work, and a plugin that killed it for a cleaner heap would be paying with data it does not own.

The advice lives as long as the work it is about: it is kept while /state.scheduling.deferred lists rows that this round held back (the next press cannot mount them either), it is cleared by a healthy round with nothing deferred, and a recommendation from more than six hours ago is dropped unless rows are still waiting. A row that was held back keeps the intention it had (mount or continue) and comes back with it on the next restore.

The three memory numbers are derived from the heap this Host actually has, not written down here: a 4 GB V8 cap and a 16 GB one cannot share a reserve, and a small cap wants one session at a time. /state.scheduling reports the resolved values plus derived, which says which of them came from the heap instead of from config.

Key Default Meaning
restoreConcurrency 1 if the V8 cap is under 3 GB, else 2 light sessions restored at once in one round
restoreHeavyMB heapLimitMB / 340, clamped to 8–48 MB compressed-transcript size that makes a session heavy
restoreHeapReserveMB 22% of the V8 cap, clamped to 320–1536 MB heap headroom below which a round mounts nothing
restoreSettleMs 90000 upper bound to wait for a heavy session's turn

All four are optional; setting one pins that number and leaves the others derived.

This is a different layer from progressive restore. restoreProgressive, restoreCapacity, restoreMaxCapacity and restoreCooldownMs limit real model requests to the upstream; these four limit how much transcript a restore holds and how many sessions it steps through. Both can be on, and neither replaces the other. The crash evidence, the scheduling flow and the deferred / restartRecommended fields are described in docs/memory-safety.md.

Supported platforms

macOS, Windows and Linux. State and logs go through node:path and node:fs only, under $DSH_HOME (or ~/.dsh when the variable is unset); nothing calls a shell and nothing shells out to stat, du or ps. Cross-process exclusion uses a session.lock file rather than POSIX-only calls. On Windows the directory fsync has no equivalent, so it is skipped — a write is still a temp file plus rename — and that, together with line endings, is the only difference between the platforms.

Installing

plugin_manager install_bundle dsh-pause-resume

or install the bundle from the Harness plugin manager with the package name. Every configuration key is optional (see Progressive restore) and the plugin holds no credentials. allowRemote exists for a Harness reached from another machine; local-only is the default.

Guarantees and limits

  • Exclusive. The phase lives on the Host and is written before any work starts, so two windows, a double press and a tool call cannot overlap. The second caller is told busy — except a pause during a restore, which interrupts it on the spot instead of refusing: stopping must never be the operation that waits.
  • Durable before destructive. No turn is cancelled until the plan is on disk. A failed write means nothing was stopped.
  • Bounded. A restore that keeps failing stays on disk with its rows named; it does not close as if the work were finished. A row that cannot be read is retried once and then either reports real progress or retires the plan as partial with per-row attention, so the button stops repeating a no-op. Sessions continued by hand are restored, never re-sent, and their attention row clears as soon as they run. An attempt that overruns its budget is watched a little longer before it is called a failure, and a session this plan already restored stays restored even if the Harness revives it and moves its turn on a later boot.
  • Idempotent. The continuation request id is derived from the plan and the session, so a retry cannot post the same message twice; a turn that already moved is not continued again, and a turn a person already continued is reported already running instead of being pressed again. The Harness has no compare-and-admit, so a sub-second window remains between the last status read and prompt admission; the guards narrow it and the outcome never claims atomicity.
  • Honest. Unreadable state never reports paused. A session deleted by hand, a goal changed by hand, a session already running: each is skipped and named in the outcome.
  • No hidden takeover. There is no public API to detach an agent, so the pause guarantee is "turns cancelled, goals paused, jobs killed" — not "the process is frozen". A goal continues because the Harness has a mechanism for that; a plain session continues through one visible user message.
  • No third-party plugin state. A plugin's in-process state belongs to that plugin's mount.
  • Local by default. The routes require a same-origin request and a plugin header. Remote use must be opted into and still cannot skip the header.

Files

All state lives under $DSH_HOME/pause-resume/:

File Purpose
snapshot.json the work a press stopped, pausing or paused
pending.json a crash plan, kept until a restore finishes
journal/<planId>.jsonl restore attempts, tagged with the boot that made them
restores/<planId>.json the archived plan, kept as a record of the last restore
last-restore.json summary of the last restore
running.json sessions with an open turn, for crash residue
continue.json turns that stopped on their own and wait for one continue press

Every write is a unique temp file, fsync, rename, then a directory fsync. State is never inside the package.

Development

npm test
npm run audit
npm run smoke:host   # boots a throwaway real Host on a free port and asserts the routes
npm run smoke:live   # real Host + local mock model stream: pause, SIGKILL, restart, resume once
npm run smoke:maintenance  # real Host: pause cancels an in-flight compaction, apart from the session turn

Verification records live in docs/acceptance-0.6.11.md (current release: repeated failures stay continuable, a pause truncates a running batch, admission ownership, handoff proof, cold/stale goal recovery, continue behind the progressive gate, and blocking vs actionable rows, with the proven and unproven halves kept apart), docs/acceptance-0.6.10.md (self-stopped turns and the one continue press), docs/acceptance-0.6.9.md (the ten defects from the previous review), docs/acceptance-0.6.7.md (a crash press that moved the button without restarting anything), docs/acceptance-0.6.6.md (an unknown row a person continued by hand and finished, child rows honouring the same intents as roots, the race window, and what is still unverified), docs/acceptance-0.6.5.md (0.6.5), docs/acceptance-0.6.0.md (0.6.0–0.6.1) and docs/evidence/. The eight-item mapping from the second review to code and proof is docs/objective-round2-closure.md. The job-lifecycle listening design review, including why a job is never restarted, is docs/design/job-lifecycle.md. The memory-safety policy behind the 0.7.0 restore pacing — why a restore kills a Host, the crash evidence, the scheduling flow and the fields a person should look at — is docs/memory-safety.md.

lib/engine.js performs no I/O, lib/snapshot.js persists, lib/probe.js holds every Harness service contract (agent identity, goal revision, per-owner jobs, continuation prompts), and index.js only wires them to the web routes. All of them are testable without a Harness: test/apply.test.mjs mounts the plugin against a fake registry.

MIT.


一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作 接回来。正常结束的会话不记录、不恢复;崩溃会接回记录里的会话与其目标,暂时无法确认状态的会话保持计划未完成,按钮继续给 ▶;日程不会被动 (该服务只有删除,没有停用),后台命令只记录不重跑。

SIMILAR PLUGINS