dsh-idle-compactor
Idle-triggered context compaction plugin for DeepSeek Harness: compact a session once it goes quiet past a configurable token floor.
- Stars
- 0
- Language
- TypeScript
- Created
- Sep 4, 2026
- Updated
- Sep 4, 2026
Introduction
dsh-idle-compactor
Idle-triggered context compaction for DeepSeek Harness.
A session that has grown past a token floor and then gone quiet gets compacted on its own, so you come back to a condensed history instead of paying for the stale tail on every request.
Built-in automatic compaction knows two triggers only: request pressure, and a provider-confirmed
context overflow. It never asks how long a session has sat unused. This plugin adds that axis — idle
time and context size, and both must clear their floor before anything happens.
Requirements
- DeepSeek Harness with the
compactionseam andtokenMeter(verified against0.1.2-alpha.3). - No runtime dependencies. Every
@deepseek-ai/*import in the source isimport typeand is erased, solib/index.jsis a single self-contained ESM file.
Install
git clone https://github.com/QuanhuZeYu/dsh-idle-compactor.git
dsh plugin --profile web add ./dsh-idle-compactor
# pnpm 9 refuses to add a dependency to a workspace root without the flag:
dsh plugin --profile web add -w ./dsh-idle-compactor
Then restart the profile: a patch layer reloads live, bundle mounting does not.
The bundle carries its committed lib/ output, so a git or path install needs no build script and no
allowBuilds allowance. dsh plugin add is what appends the bundle to the profile layer stack, which
makes its cordis.patch.yml take effect. Doing it by hand means writing the same two things into
~/.dsh/profiles/web/package.json:
{
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-idle-compactor"] } },
"dependencies": { "dsh-idle-compactor": "link:../../dsh-idle-compactor" }
}
What it does
Every sweep (default: every 30 s) walks the live agents in this process and compacts the ones that clear all of these bars:
| Bar | Where it comes from |
|---|---|
The agent is idle — no driver, no maintenance task | agent.status |
Its newest session-log event is older than idleMs | session.events.at(-1).time (durable, so a restart cannot fake a fresh session out of compaction) |
Its measured request pressure is at least thresholdTokens | ctx.tokenMeter.measure(session).totalTokens |
Its model-visible surface is at least thresholdTokens too | .surfaceTokens from the same measurement |
| Nothing new has landed since the last checkpoint | per-session compactedAt watermark |
| Its cooldown or failure backoff has elapsed | cooldownMs, retryBackoffMs |
| It is not archived | ctx.workspaceRegistry.archivedSessionIds |
| It is not waiting on background work | its own outstanding ctx.jobs, its inbox, and the live sessions it parents |
Archiving a session hides it from every grouping surface and deliberately leaves a live agent running, so the sweep names archived ids explicitly: hiding a conversation is not a request to rewrite what it shows the model. A session whose archive set is still loading is skipped too — nothing is compacted on an unclassified session.
Why pressure and surface are both required
totalTokens is request pressure: an anchor carrying the previous successful call's provider usage,
repriced only by the next call. surfaceTokens is the content compaction actually shrinks. A session
compacted moments ago therefore reads high pressure over a small surface — re-summarizing it would spend
detail to reclaim nothing — so both halves must clear the floor before a sweep acts. Those skips are
logged at debug level.
This is also why the context figure in the UI can stay high after a compaction you can see in the log: the number is the pressure anchor, and the next request is what reprices it.
Compaction runs through the standard seam ctx.compaction.compactNow(agent, signal) — the same entry
point /compact uses: an idle-session maintenance transaction with the backend's own range
selection and retention policy. The plugin picks no range and writes no summary.
Why quiet is not the same as finished
A parent that ends its turn while a background child is still working is idle by the loop's own
measure, and that wait is bounded by the child rather than by idleMs: a delegated run can take ten
minutes or three hours. Condensing the parent's history mid-wait would rewrite the context the
settlement is about to return to, so the sweep consults three further ledgers:
- the session's own outstanding jobs —
ctx.jobs.list(agent), kept down to the snapshots this session owns, which is also why a long backgroundpwshbuild holds its session; - the session's inbox, which carries a notice that has landed but not yet opened a turn;
- the live sessions this one parents, directly or transitively.
The third ledger is not redundant with the first. The harness registers no job for a continuable child
or for a child woken by send_message, and a child driver still reports idle in the window between
accepting a prompt and starting its turn — so a descendant that logged anything inside the quiet window
counts as work in flight, including one still inside its creation window. A child that has gone quiet for
idleMs stops holding the parent, and a child that is not resident in this process never held it: there
is nothing left to wake the parent for.
waitWarnMs reports a long wait instead of cutting it short: once an episode crosses it, the sweep logs
one warning for that episode and keeps waiting.
Why the bundle also enables a host-plane backend
An agent preset mounts compaction-basic inside an isolate realm, and dsh-web-app disables the host
copy, so a host fiber cannot read the realm instance. The bundle patch therefore re-enables the host row
with auto: false: it answers idle compactNow() requests and registers no pressure compaction of its
own, leaving the preset backend as the one that reacts to request pressure. A side effect users of the
minimal preset will appreciate: those sessions have no compaction backend at all otherwise, and now
they can be compacted too.
Configuration
Set it in the profile's cordis.patch.yml (later layers win, and a patch replaces the whole config, so
restate every key you keep):
- id: idle-compactor
config:
thresholdTokens: 131072 # 128K
idleMs: 600000 # 10 minutes
| Key | Default | Meaning |
|---|---|---|
enabled | true | Register no sweep at all when false. |
thresholdTokens | 131072 | Context floor. Use 128000 for a decimal 128K. |
idleMs | 600000 | Quiet window before a session is eligible. |
scanMs | 30000 | Sweep interval; bounds how long after idleMs the compaction lands. |
includeSubagents | false | Also compact sessions whose header carries origin: subagent. |
excludeArchived | true | Skip sessions the workspace registry reports as archived. |
skipAwaitingWork | true | Never compact a session with background work outstanding — its own or a live descendant's. |
waitWarnMs | 10800000 | Log one warning per wait episode once it has held a session this long. |
cooldownMs | 60000 | Minimum spacing between compactions of one session. |
retryBackoffMs | 300000 | Wait after a failed attempt or a "no compactable range" result. |
maxPerScan | 1 | Sessions per sweep, bounding concurrent summarization calls. |
dryRun | false | Log what would be compacted; write nothing. |
Unknown keys and out-of-range values throw at load.
Model experience
- One summarization request per landed compaction, made by the compaction backend on the session's routed model. No extra request per turn, no injected notice, no prompt section.
- The only model-visible result is the checkpoint node that replaces the compacted range.
- A session below
thresholdTokensis measured once and then re-armed for the next interval instead of being re-measured on every tick.
What compaction does not do
The durable log is never rewritten. A compaction appends compaction/start, compaction/summary, and
compaction/end, then lands one user/message carrying surfaceOp: replace over the selected span;
shadowedSeqs names every event it moved out of the model-visible surface, so the original text stays in
the session log and a consumer can read it back. Neither the storage index nor the archive flag changes.
Known limitations and deferred work
- Live agents only. A session with no agent in this process has no surface to measure and no agent to
run maintenance against, so closed or never-opened sessions wait until they are opened. A cold path
would have to resume an agent per candidate; that is also why
includeSubagentsdefaults to false — background children are already owned by their parent lifecycle. - A running turn is never interrupted. Eligibility requires
agent.status === "idle", andcompactNowis a between-turn maintenance task; a session whose turn has been running for hours is not idle in either sense. Its between-turn twin — quiet while a child works — is held byskipAwaitingWork, so a parent whose wait is unbounded is compacted only after that wait ends. - No settings panel. Configuration lives in the patch layer; a
settings.sectionwith hot reload is the natural follow-up.
Development
npm install # typescript + vitest as dev dependencies
npm run link-dsh -- <path-to-dsh-checkout> # resolve the harness packages from a checkout
npm run build # src/index.ts -> lib/index.js
npm test # 12 integration tests
npm run typecheck:tests # typecheck the specs too (tsconfig.check.json)
Most @deepseek-ai/* packages the source is typed against are not on the public registry, so
scripts/link-checkout.mjs links a local DeepSeek Harness checkout into this project's own
(gitignored) node_modules. It writes nothing into that checkout, and when several checkouts sit side
by side it refuses to guess — pass the one you mean. npm install is optional: without it the script
borrows the checkout's own typescript and vitest, and the npm scripts call those by file, not by bin
shim, so a borrowed copy works exactly like an installed one.
tests/idle-compactor.spec.ts composes a real agent loop, a real session log, a real token meter and a
real BasicCompactionEngine (only its summarizer is scripted), and asserts the durable
compaction/summary events the run leaves behind rather than mock call counts. It exercises the built
bundle by default — the same artifact the profile loads; point DSH_IDLE_COMPACTOR_BUNDLE elsewhere to
check a different build.
Covers: an over-threshold idle session lands exactly one compaction and its measured context shrinks; a
second quiet window with no new activity does not repeat it; fresh activity re-arms it; a below-threshold
session is untouched; a surface already under the floor is left alone while pressure reads over it; an
archived session is untouched, including while the archive set is still loading; dryRun writes nothing;
bad configuration throws. Waiting is covered from both sides: a session holding an outstanding job and a
session whose child is mid-turn are left alone and then compacted once the wait ends, while an unrelated
mid-turn session or a job owned by someone else compacts on schedule.
Layout
src/index.ts the plugin (type-only imports, zero runtime dependencies)
lib/ committed build output - what the profile loads
cordis.patch.yml the bundle layer: the plugin row plus the host-plane backend
tests/ integration tests against a composed harness
scripts/link-checkout.mjs dev-only: resolve harness packages from a checkout, read-only
vitest.config.mjs standalone test root; no harness checkout config involved
tsconfig.json erasable-syntax-only ESM, declarations into lib/types
tsconfig.check.json test-plane typecheck: src plus specs, with Node ambient types
License
MIT — see LICENSE.