Pular para o conteúdo principal
I

dsh-download-dock

imnotriv3r/dsh-download-dock

Observes detected background download commands and renders live cards with bytes, speed and ETA; silent downloads get a total from a HEAD probe.

Instalar

dsh plugin --profile web add github:imnotriv3r/dsh-download-dock

README

English · 中文

dsh-download-dock — live download cards in the corner of the DeepSeek Harness UI

License: MIT Node.js >= 20 DeepSeek Harness >= 0.2.0-rc.1 Tests: offline smoke Version 0.1.0

A background download shouldn't be one word: "Running".
dsh-download-dock grows a live card in the bottom-right corner: file, progress, bytes, speed and ETA.


A card with a known total: 32% · curl An indeterminate card: moving stripes, bytes written

Left: the total is known → a real percentage and an ETA. Right: no single total → moving stripes plus bytes written (a deliberate third state, not a failure).

You start a 2 GB model download in Harness and it goes to the background. The corner of the UI says nothing; the job row says "Running". You can't tell whether it's downloading at full speed, stuck on DNS, or already dead.

dsh-download-dock turns those forty seconds into one card: file name, 32%, 4.14 MB / 12.9 MB, 148 KB/s, 59s left. No second window, no wrapped commands, no takeover — it observes and draws the result where you are already looking.

Contents

Expand

The problem

Harness pushes download commands into background jobs (run_in_background: true) so they don't block the turn. The cost: the UI keeps a single status row, and the next real signal is a completion notice a dozen seconds later. Everything in between is invisible.

dsh-download-dock fills that gap, and it only observes:

It doesIt doesn't
Subscribe to job registry events and detect download commandsConsume the model's job cursor
Sample disk bytes, probe totals (Host) and parse job output (browser)Change job state, send signals or kill processes
Expose a read-only JSON route to the browser halfRead or write the downloaded files
Render a card in the bottom-right cornerPop dialogs, steal focus or block input

It never asks you to wrap a command. git clone, pip install, curl -o, npm install and winget install work as they are; progress-friendly flags only make the card more precise (see What it watches).

Anatomy of a card

A card is 366 px wide (clamped to 100vw - 36px on narrow screens), fixed 18 px from the bottom-right corner, with a blurred glass background that follows Harness' light and dark theme variables. Top to bottom:

PartContentNotes
Tool badgegit / py / npm / a2 / c / w / web / app / rs / go / dkDerived from the detected tool
TitleFile, repo or package namee.g. numpy-2.2.6-cp310-win_amd64.whl, dsh-download-dock, lodash; falls back to the first 60 characters of the command
SubtitleByte progress or status text4.14 MB / 12.9 MB, 12.4 MB written, Completed, Download failed, Cancelled, Stopping…
BarFilled bar or moving stripesFilled when the total is known; stripes otherwise (see Stripes are not a bug)
Bottom leftPercentage + toolThe tool alone means indeterminate
Bottom rightSpeed · ETA · elapsedOnly elapsed once finished
Error lineFailure reasonfailed only; first line of the job's detail

Status maps directly onto the visuals:

StatusBarSubtitleBottom right
runningfilled or stripedbytes / status textspeed · ETA · elapsed
stoppingunchangedStopping…speed no longer updates
completedgreen, 100%Completedtotal elapsed
failedredDownload failed + error linetotal elapsed
killedredCancelledtotal elapsed

The ✕ button only removes the card from the current page — it never stops the job. Reloading brings the card back if the job is still running.

How it works

The plugin is split into two halves that talk over a single read-only JSON route:

dsh-download-dock data flow: download tools → Host sampling → JSON state → browser card

HalfFilesRuntimeResponsibility
Hostindex.js + lib/*Harness main process (Node.js)Subscribe to job events, detect downloads, sample disk, probe totals, expose the state route, inject a prompt section
Browserclient.jsWeb GUI (React)Poll state, read job output, parse progress, merge into cards, portal into document.body

Lifecycle of one download

flowchart LR
    A["Background job starts"] --> B{"detectDownload()<br/>is this a download?"}
    B -- no --> X["No card"]
    B -- yes --> C["Record baselines<br/>target dirs + %TEMP%"]
    C --> D["HEAD probe for total<br/>single URL only"]
    C --> E["Sample every 700 ms<br/>disk byte delta"]
    D --> F["present() merge<br/>output first, disk as fallback"]
    E --> F
    F --> G["Browser polls every 600 ms<br/>renders the card"]
    G --> H{"Job finished?"}
    H -- no --> G
    H -- yes --> I["completed / failed / killed<br/>fades out after 2.6 s"]
  1. A command starts with run_in_background: true and the job registry emits an event.
  2. detectDownload(label) decides whether it is a download and resolves candidate output paths.
  3. The Host records baselines: current bytes of the target directories and of this tool's existing %TEMP% entries.
  4. If the command contains exactly one HTTP(S) URL, the Host sends a HEAD request and reads content-length as the total.
  5. Every 700 ms the Host samples only the delta, so pre-existing files never count as part of this download.
  6. After the job ends, the Host keeps returning it for 12 seconds (settledTtlMs) so the browser can render the final state.
  7. The browser fetches /plugin/dsh-download-dock/state every 600 ms and pauses while the page is hidden.
  8. The browser merges disk bytes with the downloader's own progress lines, renders the card, and fades finished cards out after 2.6 seconds.

Three data sources

A. Job output (most accurate)

The browser subscribes to the job's output and scans its last 16 KiB (up to 60 lines) for progress frames:

DownloaderSample outputParsed fields
aria2c[#1a2b3c 12MiB/45MiB(26%) CN:1 DL:1.2MiB ETA:30s]percent, downloaded, total, speed, ETA
git cloneReceiving objects: 45% (1234/2740), 1.20 MiB | 2.30 MiB/spercent, downloaded, speed
docker pull45%|████ | 12.3MB/27MBpercent, downloaded, total
pip12.9/12.9 MB 491.0 kB/s eta 0:00:00downloaded, total, speed, ETA
wget0K .......... .......... 45% 1.2M 3spercent, speed, ETA
curl -################## 45.0%percent
genericany line containing 45%percent

A few details that matter:

  • pip's total comes from its first line. Downloading https://.../p.whl (9.7 MB) is remembered separately; later progress frames only carry bytes downloaded, and the two together produce a percentage.
  • git only counts Receiving objects. Resolving deltas, Counting, Compressing, Writing, Updating and Enumerating are local phases; treating their 100% as download progress makes the bar jump backwards.
  • Speed fallback. When a line has no MiB/s, the parser also looks for a standalone X MB/s token in the same line.
  • Swallowed output is gone. curl -# ... | Select-Object and friends eat stdout/stderr; that data source is unavailable and only disk sampling remains.

B. Disk sampling (covers everything that writes files)

The browser cannot read the filesystem, so the Host samples. Targets aren't guessed — they are parsed from the command text:

ClueSampled target
A URL's file name<cwd>/<file>, <file>.part, <file>.tmp
-o / -O / --output / -OutFile / -Destination / --dir and friendsThe resolved real path
git cloneThe target repo's .git (the working tree is never walked)
npm / pnpm / yarn / uvnode_modules and .venv under the working directory
$dir="D:\dl" in the commandExpanded before resolution; if a variable cannot be resolved the path is skipped rather than guessed
Everything%TEMP% entries matching the downloader's prefix, plus .part / .partial / .crdownload / .opdownload staging files

%TEMP% matching is scoped by downloader prefix (pip-, uv-, npm-, pnpm, npx-, yarn-, git-, winget-, choco-, scoop- …) because a Windows temp directory routinely holds hundreds of unrelated .tmp files.

Sampling has hard budgets so a huge directory can't stall the main process:

ParameterValuePurpose
Per-round budget220 ms (50–2000, configurable)Stops walking as soon as the budget is spent
Target / temp split45% / 55%Neither path can starve the other
Recursion depth3 levelsAvoids descending into nested node_modules
Entry cap per round4000Keeps giant trees from eating the whole budget
Sampling tick700 msSmooth enough, cheap enough
Speed window5 sByte delta between the window's first and last sample
Smoothing0.55 × old + 0.45 × newDamps sampling jitter

Every byte count is a delta: the Host records the target directory and %TEMP% baselines when the task opens, then counts only growth. Files that were already in the directory never count as this download.

C. HEAD probe (supplies the missing total)

For a single-file URL (exactly one HTTP(S) address in the command), the Host sends one HEAD request:

  • 3 s timeout, at most 3 redirects;
  • only a 2xx response's content-length counts (a 404 error page has a length too, and it would be wrong);
  • failures, non-HTTP(S) URLs and servers that refuse HEAD are silently ignored, and the card falls back to stripes or to the output-derived total;
  • multi-URL commands are not probed — batch downloads have no single total, so any probe would be wrong.

Merge rules

The browser merges the two paths like this:

FieldHow it merges
downloadedmax(job output, disk delta) — either can lead temporarily, and the max means progress never steps backwards to zero
totaljob output (including pip's (9.7 MB)) → HEAD content-length; neither → stripes
percentjob output → downloaded / total × 100; neither → stripes
speedjob output → disk sampling speed; neither → hidden
etajob output → (total − downloaded) / speed; neither → hidden

A real percentage appears only when the job output carries one, or when the output/HEAD supplies a total. Everything else degrades honestly.

Stripes are not a bug

Moving stripes mean the plugin is in its indeterminate state: it knows a download is running, how many bytes have landed and how fast, but not the total, so it honestly refuses to invent a percentage.

There are only three ways to end up without a total:

  1. The downloader doesn't report one (curl -s, wget --quiet, or output swallowed by a pipe);
  2. The command isn't a single HTTP(S) URL (many packages, many URLs) — there is nothing to HEAD;
  3. The server refuses HEAD, returns a non-2xx status, times out, or probeTotals is disabled.

This is not a fallback to failure but a deliberate third state: don't fake certainty. The card still shows 12.4 MB written and a live speed, so the information isn't zero.

When you will always see a percentage:

ScenarioPercentageTotal source
curl -# -L -o file URL✅Hash progress line
curl -s -L -o file URL✅HEAD probe
git clone --progress✅Receiving objects
aria2c✅Its own progress line
docker pull✅Its own progress line
wget✅Its own progress line
pip install / python -m pip download✅pip's (9.7 MB) + disk delta (when the output carries a total)
npm install / pnpm add❌ stripesDozens of packages, no single total
Multi-URL curl / wget❌ stripesSame reason
Silent download from a server without HEAD❌ stripesNo source at all

What it watches

Detection looks only at the command text (job.label) and takes the first matching rule, in a fixed order:

ToolMatching commandsProgress sourceBadge
gitgit clone / fetch / pull / submodule update / lfs pulloutput lines + .git samplinggit
pippip install / download / wheel, including python -m pipoutput lines + %TEMP% samplingpy
uvuv pip install / add / sync / lock / download / exportdisk sampling + %TEMP%py
npm familynpm / pnpm / yarn / bun with i, install, ci, add, dlx, create, update, fetch, rebuilddisk sampling (node_modules)npm
curlAny curl call that has a URL or an output flag-# output + target samplingc
wgetAny wget calloutput lines + target samplingw
aria2aria2coutput lines + target samplinga2
PowerShellStart-BitsTransfer; Invoke-WebRequest / iwr / Invoke-RestMethod / irm with -OutFiletarget samplingweb
Package managerswinget install / download, choco install, scoop install / update, nuget install, dotnet tool install / add package%TEMP% samplingapp
Rust / Gocargo install / fetch / vendor / update, go get / mod download%TEMP% samplingrs / go
Dockerdocker pull / load / save / buildoutput linesdk

Explicitly ignored (no card, no sampling):

  • git status, git log, git commit and other non-fetching commands;
  • curl --version, wget --help, aria2c -h — version/help only, with neither a URL nor an output flag;
  • pip list, pip freeze — no install/download action;
  • Invoke-WebRequest without -OutFile (usually an API call read into memory, not a download);
  • npm run build, npm test — run isn't a download verb;
  • commands longer than 8000 characters (keeps pathological labels from slowing the parse).

Install

Prerequisites:

  • DeepSeek Harness >= 0.2.0-rc.1 < 0.3.0-0 (from dsh.compatibility in package.json);
  • Node.js >= 20;
  • a working Harness profile (examples below use desktop; substitute yours).
git clone https://github.com/imnotRiv3r/dsh-download-dock.git E:\dshWorkspace\dsh-download-dock

# Link it into the profile's node_modules (a Windows directory junction; usually no admin needed)
cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-download-dock" "E:\dshWorkspace\dsh-download-dock"

Then add the dependency and the bundle to the profile's package.json:

{
  "dependencies": {
    "dsh-download-dock": "link:E:/dshWorkspace/dsh-download-dock"
  },
  "dsh": {
    "profile": {
      "bundles": ["...", "dsh-download-dock"]
    }
  }
}

Option B: install from GitHub

cd "$env:USERPROFILE\.dsh\profiles\desktop"
npm install --save "github:imnotRiv3r/dsh-download-dock"

Then add "dsh-download-dock" to dsh.profile.bundles in the same package.json. The repo has no build step — exports points straight at the source, so it works right after install.

What reloads when

ChangeTakes effect
Host half (index.js, lib/*)Hot reloads with profile changes
Browser half (client.js)One page refresh
Config (cordis.patch.yml)Hot reload; the browser half picks it up after a refresh

Plugin page

locale/en.json and locale/zh.json carry meta.title and meta.description, and icon.svg is the plugin-page icon (through the icon field in package.json plus ./locale/*.json and ./icon in exports). After install, rename or update, refresh the page once and the plugin page shows "Download dock" / "下载坞" with its icon. If it still falls back to the package name or default artwork, the host has not re-read the package manifest yet — restart DSH.

Verify

# Is the Host half alive? `hits` grows once the browser half attaches (use your DSH Web port).
Invoke-RestMethod http://127.0.0.1:2482/plugin/dsh-download-dock/state

A healthy response looks like:

{ "version": 1, "now": 1731427200000, "tasks": [], "poll": { "hits": 412, "lastHitAt": 1731427200000, "build": "0.1.0" } }

Now trigger a visible download (rate-limited to 300 KB/s so the card has time to appear):

curl.exe -L -# --limit-rate 300k -o "$env:TEMP\probe.bin" `
  https://mirrors.aliyun.com/pypi/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl

It must be a background job (run_in_background: true, or a foreground command promoted to background on timeout). Foreground commands have no job row, so there is nothing to observe — that follows from Harness' job model.

Config

The row id is download-dock; edit it in the profile's cordis.patch.yml:

- id: download-dock
  name: dsh-download-dock
  config:
    enabled: true
    guidePrompt: true
    settledTtlMs: 12000
    maxTasks: 8
    sampleBudgetMs: 220
    probeTotals: true
    workingDirectory: ''
FieldTypeDefaultRangeMeaning
enabledbooleantrue—Master switch: off means no detection, no sampling, no route
guidePromptbooleantrue—Add the download guidance section to the system prompt
settledTtlMsnumber120001000–120000How long the Host keeps returning a finished task
maxTasksnumber81–32Maximum cards rendered at once
sampleBudgetMsnumber22050–2000Per-round disk sampling budget; lower it on huge directories
probeTotalsbooleantrue—HEAD-probe single file URLs for a total; off means silent downloads stay indeterminate
workingDirectorystringprocess cwd—Base for resolving relative paths; empty uses the Harness process cwd

With guidePrompt on, the plugin injects a short system-prompt section: for download commands prefer run_in_background: true, and add progress-friendly flags (git clone --progress, curl -# -L; wget and aria2c already report progress). That section is what determines how often cards actually appear — the model has to put downloads in the background before there is anything to observe.

Config values are normalized on the Host side (numbers clamped to range and rounded, blank strings fall back to the process cwd), so a malformed value can't crash the plugin.

The state route

GET /plugin/dsh-download-dock/state returns a read-only JSON snapshot (cache-control: no-store):

{
  "version": 1,
  "now": 1731427200000,
  "tasks": [
    {
      "id": "job_7f3a",
      "sessionId": "sess_2b91",
      "tool": "pip",
      "fileName": "numpy-2.2.6-cp310-win_amd64.whl",
      "label": "python -m pip download numpy --no-deps -d E:\\ws",
      "status": "running",
      "startedAt": 1731427188000,
      "finishedAt": null,
      "elapsedMs": 12000,
      "bytes": 4140000,
      "speed": 148000,
      "total": 12900000,
      "detail": null,
      "source": "temp"
    }
  ],
  "poll": { "hits": 412, "lastHitAt": 1731427200000, "build": "0.1.0" }
}
FieldMeaning
versionSnapshot format version, currently 1
nowWhen the Host built the snapshot
tasks[].idHarness job id
tasks[].sessionIdOwning session; null for ownerless jobs, which can only show bytes (their output stream is unavailable)
tasks[].toolDetected tool (git, pip, npm, curl …)
tasks[].fileNameCard title (file / repo / package name)
tasks[].labelFull command text (shown as the card's hover title)
tasks[].statusrunning / stopping / completed / failed / killed
tasks[].elapsedMsMilliseconds elapsed (total once finished)
tasks[].bytesMonotonic disk delta in bytes
tasks[].speedSmoothed bytes per second
tasks[].totalTotal size; null when unknown
tasks[].detailFailure reason (null on success)
tasks[].sourceWhether bytes mainly came from target or temp
poll.hitsRequests to this route — direct evidence the browser half is mounted
poll.buildBuild marker of the Host that answered

For debugging, add ?diag=... to the URL: the value is truncated to 4000 characters and echoed in poll.diag, so you can confirm which build the browser actually reached.

Troubleshooting

SymptomLikely causeFix
No card at allNot a background jobUse run_in_background: true; foreground downloads never show a card
Command doesn't look like a downloadCheck it against the list above; npm run build and git status don't count
Browser half not loadedRefresh the page; confirm dsh-download-dock is in bundles
Plugin disabledCheck enabled; look for disabled by config in the Host log
webServer not readyThe plugin retries every 500 ms, 20 times; after that you'll see webServer never became available
Card appears but bytes stay at 0Download doesn't hit disk, or lands somewhere unresolvedUse an explicit -o / -OutFile; make sure $var paths expand to real absolute paths
Directory too deep / too many entriesRaise sampleBudgetMs, or download into a shallower directory
Card always shows stripesNo total availableHEAD only runs for a single HTTP(S) URL with probeTotals: true; multi-package / multi-URL downloads have no single total
Card flashes and disappearsTask was very shortCards stay hidden for the first 500 ms of a run; finished cards still show for 2.6 s, so a quick fade-out is normal
Card only appears after a refreshThe browser half doesn't hot reloadExpected — client.js changes require a page refresh
Numbers freeze mid-downloadThe job itself is stuck (DNS, TLS, silent server)The card truthfully shows the last byte count and a zero speed; it does not guess
Speed curve wobbles on huge directoriesSampling budget too smallRaise sampleBudgetMs, or accept the estimate (see Limitations)
Two pip runs bleed into each other%TEMP% is sharedPrefix filtering makes it rare, but concurrent runs of the same tool can still mix; run them serially

Three-state diagnosis with poll.hits:

hitsMeaningNext step
Not growingBrowser half isn't loadedRefresh; check profile bundles; check the browser console
Growing, no cardMounting or data problemCheck whether /state returns non-empty tasks; verify the page renders outside shell.overlay
Growing, card present, numbers frozenSampling isn't matchingInspect tasks[].source and bytes; check targets and permissions

Limitations

  • Foreground downloads never get a card. This follows from Harness' job model and cannot be worked around; run_in_background is the only entry point.
  • Stripes are not a bug. Without a total there is no honest percentage. npm install and multi-URL batch downloads have no single total by design.
  • pip / npm print no live progress in a non-TTY pipe. The percentage is disk delta ÷ total; pip's total comes from its own opening line, and npm has even less, so it usually stays indeterminate.
  • %TEMP% is shared. Two concurrent pip downloads can bleed into each other's byte counts; prefix filtering only makes it rare, not impossible.
  • Disk sampling is an estimate, not accounting. Depth 3, 4000 entries, 220 ms budget: very large trees are partially counted and the curve can wobble.
  • HEAD probing covers a single URL only. Non-HTTP(S) addresses, multi-URL commands and servers that refuse HEAD get no total, and probe failures are silent.
  • Ownerless jobs show bytes only. A job that doesn't belong to a session has no output stream to observe.
  • State lives in memory. Restarting Harness clears everything; there is no history and no persistence. This is not a download manager.
  • Sampling is single-threaded and serial. More concurrent downloads means slower rounds and duller speed curves; the ledger keeps at most maxTasks × 2 tasks and the snapshot exports the first maxTasks.
  • It doesn't verify downloads. Hashes, signatures, extraction and installation are out of scope; it only looks at bytes.
  • It doesn't throttle or schedule. It watches downloads, it doesn't manage them.

Development

git clone https://github.com/imnotRiv3r/dsh-download-dock.git
cd dsh-download-dock

node test/smoke.mjs   # detection, parsing, merging, browser-half registration — fully offline
npm run check         # node --check on every source file

Project layout:

FileResponsibility
index.jsHost entry: config normalization, job subscription, state route, prompt injection
lib/detect.jsCommand detection, URL / output-flag / variable expansion, target resolution
lib/tracker.jsTask ledger, 700 ms sampling tick, speed smoothing, snapshot export
lib/sampler.jsBudgeted directory byte counting, %TEMP% prefix filtering, baselines
lib/probe.jsHEAD content-length probe (timeouts, redirects, status validation)
client.jsBrowser half: polling, output parsing, merging, card rendering and portal mounting
test/smoke.mjsOffline smoke test built from excerpts of real command output
docs/*.svg, docs/*.pngREADME artwork and card renders

The smoke test covers 10 commands that must be detected, 7 that must not, shell variable expansion, pip -d exclusion, invalid probeContentLength inputs, git / aria2 / wget / curl progress frames, present()'s percentage and ETA, and client.js's module and slot registration. Fixtures are excerpts of real output, and nothing touches the network.

Design decisions

DecisionWhy
Mount with ReactDOM.createPortal into document.bodyThe shell.overlay container carries transform / overflow, so a position: fixed card inside it gets clipped or positioned against that ancestor — the component runs but nothing is visible
700 ms sampling / 600 ms pollingFast enough to look continuous, cheap enough to leave the main process and browser alone
downloaded = max(output, disk)Either path can lead temporarily; taking the max avoids progress stepping backwards
Deltas, not absolute sizesDownload folders usually contain old files that must not count toward this download
HEAD for single URLs onlyMulti-URL downloads have no single total, so any probe result would be wrong
Observe, never interveneThe plugin must not affect job lifecycle, completion notices or model context
No hot reload for client.jsHarness registers browser modules at page load; edits require a refresh

License

MIT © imnotRiv3r


If this card saves you a few terminal stares, a ⭐ is enough.

Plugins relacionados