dsh-global-task-list
v0.2.12
Published
Global task library with a persistent interactive panel: task CRUD tools, linked-subagent terminal-status sync, and a generative-UI surface renderer for the DeepSeek Harness
Downloads
2,643
Readme
dsh-global-task-list
A global task library for the DeepSeek Harness: six model-facing tools, a persistent browser panel, subagent job status synchronization, and a generative-UI surface renderer. Tasks persist across sessions in a storageDomain unit and survive restarts.
全局任务列表插件:为 DeepSeek Harness 提供一个跨会话持久化的任务库,附右下角常驻面板(轨迹等非对话界面自动隐藏)。主 agent 用
task_add创建任务(带描述与进度),spawn 子 agent 后关联jobId自动同步终态;面板支持手动改状态、删除、拆分,并实时(SSE)刷新。任务可携带surface结构化文档渲染生成式 UI(进度条、时间线、表格、DAG 等)。
What it does
- Global task library —
task_add/task_list/task_get/task_update/task_deletemodel tools over a cross-sessionstorageDomainunit (task_ui). Tasks carry a title, status (pending/running/done/blocked/failed), description,nextStep, an orderedstepsflow,parentId,dependsOn, an optionalprogress({ text, percent? }), and an optionalsurfacedocument. - Persistent panel — a bottom-right floating panel (mounted in the session-scoped
conversation.input.dockslot) subscribes to a Server-Sent Events channel (/task-ui/events) and refreshes on every task change, letting the user change status, delete (with confirmation), and trigger a generative split loop. Clicking a card expands a secondary card under it with the description, the step flow, dependencies, and timestamps. Styled with--dsw-*theme tokens and localized zh/en. It disappears on non-conversation views (e.g. trajectory). - Step flow — the detail card describes progress as a vertical flow rather than a bar: finished and pending steps share the plain frame, while the two that matter are drawn differently —
currentin a solid brand frame with a faint tint,nextin a dashed one. Steps come from the owner'ssteps(done/current/next/todo); when it wrote none the panel derives a node fromprogress, and a list naming nonextinheritsnextStepas one. A step's state is drawn, so it is also announced to screen readers as text. - Panel motion — a new task rises in while the bottom-anchored panel slides up by the height it gained, which is what lifts the cards above it; a deleted card holds its slot through its exit animation and the panel retracts afterwards. Expansion is CSS-only (
grid-template-rows: 0fr → 1fr). All of it is skipped underprefers-reduced-motion. A removed card is re-adopted as a ghost row inside a layout effect, so it never leaves the DOM for a painted frame — that gap was what made the whole list flash and the panel run a shrink/grow/shrink cycle on delete. - Resizable — drag the panel's left edge (or focus it and use ←/→) to set its width between 260px and 760px; the width is remembered in
localStorage. The gesture follows ui-layout's ownDragHandle: pointer capture for the whole drag, one throttled update per animation frame, and the width computed from the drag origin rather than accumulated per move. - Subagent job sync — a task linked to a background job via
task_update(id, { jobId })auto-advances its terminal status through theJOB_STATUS_MAP(completed→done,failed→failed,killed→blocked). Tasks are created explicitly by the master agent, never auto-registered from arbitrary background jobs. - Generative UI surface — a task's
surfacefield renders a whitelisted, recursive component tree:section,metric,statusBadge,progress,table,list,timeline,dag,disclosure.
Install
Installing from npm is the recommended path — the package ships prebuilt lib/, so no build step runs at install time. The GitHub alternative pulls source and builds on install (see below).
# npm (recommended, prebuilt — no build step at install)
dsh plugin --profile web add dsh-global-task-list
# GitHub (alternative: source + prepare build)
dsh plugin --profile web add github:sgzxs/dsh-global-task-list#v0.2.0The package declares dsh.bundle, so dsh adds it to the profile's bundles automatically. Requires a DSH installation with @deepseek-ai/dsh-base and the client surface (dsh-web-app) present.
Use at least 0.2.0 for a 0.2.x DSH runtime (the desktop app is 0.2.0-rc.2): earlier releases declare 0.1.x-only DSH peer ranges, and the runtime rejects them before pnpm runs, with installation rejected: Plugin [email protected] is incompatible with dsh 0.2.0-rc.2. The desktop app additionally manages its profile itself — see Installing on the desktop app.
Which version did I actually get?
Not necessarily the one that was displayed. A marketplace shows the version a registry reports, while the install is a package-manager run that can resolve differently — an exact pin in the profile's package.json, a pnpm-lock.yaml entry, or a registry asked in turn (DSH's plugin manager falls back to registry.npmmirror.com when the first one is unreachable or answers that it has no such version, and a mirror lags npm). Nothing reconciles the two, so "the marketplace said 2.10 but I got 2.3" is a resolution result, not a stale download.
Two ways to see the truth:
- ask the agent to run
taskui_probe— it answers[task-ui] v<version> host alive, tasks=N; the host log line[task-ui] host plugin loaded v<version>says the same at startup; - read the profile:
<dshHome>/profiles/<profile>/node_modules/dsh-global-task-list/package.json.
To make an update actually land, name the version explicitly — an exact version also lets the manager's registry plan move past a mirror that does not have it yet:
dsh plugin --profile web add [email protected]And to keep receiving updates, depend on a range rather than an exact version: a profile pinned to 0.2.2 will keep installing 0.2.2 no matter what the marketplace shows.
GitHub install: allowBuilds
Installing from GitHub pulls source, so pnpm must run the package's prepare script to build lib/. pnpm blocks that by default for git-hosted packages, and its allowBuilds key matches the exact commit (a package-name-only entry does not match — see pnpm#12367). The first add therefore fails with an ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED message that prints the exact key to allow. Copy that exact key into the profile's pnpm-workspace.yaml, then re-run:
allowBuilds:
dsh-global-task-list@git+https://github.com/sgzxs/dsh-global-task-list.git#<commit>: truePin a tag (e.g. #v0.2.0) in the install command so the commit hash — and therefore the allowBuilds key — stays stable.
Requirements
- DeepSeek Harness (
@deepseek-ai/dsh) at0.1.2-alpha.2or later: the client bundle importsdefineStorefrom@deepseek-ai/dsh-client-store, a browser module-table entry that does not exist on earlier hosts (there the panel fails to load withFailed to load plugins). Verified against0.1.5-rc.2(CLI/web profiles) and0.2.0-rc.2(the DeepSeek Harness desktop app). - The Host half resolves
@deepseek-ai/dsh-tools,@deepseek-ai/dsh-llm,@deepseek-ai/dsh-storage-domain, andzodfrom the profile's node_modules. - The client bundle is prebuilt (
lib/client.js) and ships with the package; no build step runs at install time.
DSH version compatibility
The Host half is written against two DSH generations because the jobs service changed shape in 0.2:
| Surface | 0.1.x | 0.2.x | This plugin |
|---|---|---|---|
| Job change feed | ctx.jobs.onJobsChanged(listener) (listener receives the owner Agent) | ctx.jobs.events.subscribe({ owners: 'all' }, listener) (each commit carries the job snapshot) | feature-detects jobs.events.subscribe first, falls back to onJobsChanged, and disables the bridge with a log line if neither exists |
| ctx.jobs.list(caller) | caller is the owner Agent | caller is the owner's session id | only used on the 0.1.x path, which is the path that needs it |
Everything else the plugin touches is unchanged between the two: defineTool / tools.register, createUserMessage, defineDomain / domainTable / storageDomain.open, webServer.register({ kind, path, handler }), agents.list() / agent.followup / session.header.origin, and the domain/changed event. The client half needs no version split at all — the browser platform module table (react, react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis, dsh-client-store, dsh-client-ui-slots, dsh-client-ui-primitives, dsh-client-ui-dockkit) and the conversation.input.dock slot contract are identical in both.
peerDependencies therefore admit both lines (^0.1.5-rc.2 || ^0.2.0-rc.2). DSH enforces every @deepseek-ai/dsh* peer range against the running runtime at install time and again at profile composition, so a range that excludes the running version blocks the plugin instead of degrading.
Installing on the desktop app
The desktop app owns its profile: dsh --profile desktop is refused outside the Electron application, and dsh plugin --profile desktop <args> expects the app to be fully quit first. Either install through the app's own Plugins page, or place the package and declare it by hand:
// $DSH_HOME/profiles/desktop/package.json
{
"dependencies": { "dsh-global-task-list": "link:../../plugins/dsh-global-task-list" },
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-global-task-list"] } }
}Profiles reload their configuration by default (dsh-hmr), so a running desktop app mounts the plugin on its next recompose — no restart required.
Build (development)
npm install
npm run build:host # tsdown bundles src/index.ts → lib/index.js
npm run build:client # tsdown bundles src/client → lib/client.js
npm run typecheck # tsc --noEmit, against the real DSH 0.2.0-rc.2 types
npm test # builds, then typecheck + smoke + render + CSS-classes
npm run sync web # copy the build into a profile's node_modules (dev helper)Both halves are TypeScript: the Host half (src/index.ts) compiles to plain ESM, and the client half (TSX) compiles with CSS Modules (lightningcss) into a __ModuleLoader__ closure-factory bundle.
Why the test suite exists
The build only transpiles — tsdown runs with dts: false and never type-checks — so nothing in the build path catches a wrong identifier. npm test closes that with six layers, each of which caught something the others missed:
| Layer | Catches |
|---|---|
| tsc --noEmit | undefined identifiers, wrong argument types, stale locale keys (the dictionaries are the key-set source of truth) |
| test/host-smoke.mjs | what the Host half actually contributes: the six tools, their converted JSON Schema (including the object-typed steps items), both HTTP routes, the job-bridge subscription, and the system-prompt section. It also drives the storage lifecycle: a transient already-open is retried, and disposing the plugin closes the handle it owns. |
| test/client-smoke.mjs | module-eval and apply() throws — what a browser reports as "Failed to load plugins" |
| test/render-check.mjs | render-path throws across every task state. DSH isolates each slot entry, so a component that throws renders nothing: the panel disappears while the rest of the UI stays healthy. The harness runs the real component through a minimal hook runtime and renders child components too. |
| test/class-check.mjs | a css.foo with no .foo rule (and the reverse) — the class map is a plain object, so neither side is typed |
| test/bundle-css-check.mjs | the same split in the built artifact: every class in the bundle's exported map must have a rule in the CSS text that bundle injects. Source and build can disagree. |
The typecheck needs the DSH packages as devDependencies; they are pinned exactly to the runtime line the plugin targets, and they never ship (files lists only lib/ and the patch).
Model Experience
Request context and condition
The package contributes six model-facing tools to the agent's tool catalog.
What the model sees
taskui_probe— reports the plugin's host-alive status and current task count.task_list— one triage line per task:- [status] title (id[, job=...]) — next: ....task_get(id)— the full record of one task, ordered the way a session picking up someone else's work needs it: status and title,progress(with percent),next, the wholestepsflow with its states, dependencies, parent, job, description, and timestamps.task_add(title, description?, nextStep?, steps?, parentId?, dependsOn?, surface?, progress?)— creates a task (initialpending).task_update(id, title?/status?/description?/nextStep?/steps?/parentId?/dependsOn?/surface?/progress?/jobId?)— patches one task;progressis{ text, percent? },nextStepis the one-line "what happens next",stepsis the ordered[{ text, state }]flow the detail card draws,surfaceaccepts a structured generative-UI document, andjobIdlinks the task to a background job for terminal-status sync.task_delete(id)— deletes one task.
The list is deliberately short and the detail lives in task_get, so the model's read path is "filter with task_list, then read the one task you are about to work on". The durable record carries far more than any single line could, and a later session has no other way to reach it.
nextStep and steps postdate the original record schema. Both are optional and defaulted, and the domain's backend descriptor is derived from the domain name, version, and table names rather than the zod schemas — so adding a column needs no version bump, and records written before it existed still load. steps items are validated by the durable zod boundary on write, so the tool parameter declares them as loose objects (additionalProperties: true) instead of duplicating that enum in JSON Schema.
The surface document is a recursive, whitelisted component tree (section / metric / statusBadge / progress / table / list / timeline / dag / disclosure) rendered by the browser panel. Unknown kinds are ignored by the renderer; the Host stores the document as opaque JSON.
How the agent is made to use it
A skill is model-invoked: the catalog the model sees carries only each skill's name and description, so task-ui loads exactly when the model decides that description matches — which is not when ordinary work starts. That is the wrong lever for behaviour that must happen unprompted, so the plugin uses the levers that are always present instead:
Tool descriptions — the schemas sit in the system prompt's tool catalog on every request. Each one states its purpose, the moment to act, and its boundary (the same shape the harness uses for its own
todo_write, which is proactive for exactly this reason).task_addsays to record multi-step work before starting it and always before delegating — and to record it late rather than not at all;task_updatesays to keep the entry moving, to link a delegated job, and how to finish one properly (clearnextStep, leavestepsalldone, write the outcome intodescription).The skip boundary is deliberately a shape test, not a length test. It first read "skip trivial single-step requests / one-shot answers", and a repo analysis walked straight through it: "analyze X" is one exchange with one deliverable, so a model could reasonably file it under "one-shot". It now names the shapes that count — analyze / investigate / compare / implement / review, and anything the user calls a task — and keeps exactly one exemption: a reply you can give without looking anything up.
One system-prompt section (
task-ui:library, at the subagent tool's own order) — one sentence in the harness's own register, so a model that never reaches for a tool still knows the library exists. It covers the whole obligation, not just the write half: record before starting, keep current while working, read the record back before continuing what an earlier session started, name the shapes that count as multi-step, and record late rather than not at all. It renders two variants: the session that owns the work gets all of it, while a subagent (read fromAssembleContext.agent.session.header.origin) is told only to keep its task's record true — it does not plan, delegate, or create parallel entries its spawner cannot see. It renders empty in any scope wheretask_addis not callable, andconfig.promptSection: falsedrops it for a deployment that prefers to rely on the descriptions alone.Just-in-time notes in tool results — the write tools report a derived inconsistency in their own result, and say nothing otherwise.
task_addnames the fields a title-only entry is missing;task_updateon a task that just becamedonenames the specific gaps (anextStepstill set, steps not markeddone, an emptydescription). These restate no rule and repeat no guidance — they read the record they just wrote and report what is wrong with this one — so they cost nothing until something is actually wrong, and they land at the moment it can still be fixed. A tool result is appended to the conversation, so unlike a section it never touches the cached prompt prefix.The operator manual rides the first library call of a session — the bundled skill's body is attached to the result of the first
task_*call, once per session, so a model that never calls theskilltool still reads the manual at the moment it decides to use the library. Delivering it in a tool result matters for three reasons: it lands in the same turn (a section or context could only take effect on the next request), it costs nothing in sessions that never touch the library, and it is appended to the conversation rather than injected into the prompt, so the cached prefix is untouched. The body is read from this package's own bundled copy rather than throughctx.skills, because a host-plane plugin cannot rely on seeing a provider that an agent preset mounted in its own scope; front matter is stripped, and the text stays under the tool-result pruner's threshold.An orchestration hook delivers it earlier — a
tools/post-executelistener watches for the calls that reveal the model is organising multi-step work:todo_write(it decided to track steps), anysubagent*spawn (it is delegating),workflow/ralph. The first one in a session attaches the manual to that call throughadditionalContexts— the harness's own channel for "attach context for the next request" — so the called tool's result is left exactly as its owner wrote it. This is the difference between explaining the library after the model already recorded something and explaining it as the model starts arranging the work. The message is attributed with this plugin's ownMessageSourceMapkind and declaresform: 'instructions'(the semantic vocabulary's term for instructions read out of a file), per the harness's rule that each producer declares its own kind rather than sharing a catch-all.Both delivery channels go through one once-per-session set, so exactly one of them wins and the manual is never sent twice.
Sections are merged by name, so this one — a unique name no DSH package or preset uses — is never shadowed by, and never shadows, the deployment's persona: all of them simply render in order sequence (deployment:persona-prefix at 0, this at the subagent tool's order, deployment:persona-suffix at 10200).
The bundled skill remains the place for the detailed workflow (the subagent supervision loop, the flow-authoring rules, the cross-session handoff read order, the surface component catalog). It is not injected into the prompt — a model needs to decide to use the library before that detail is worth its tokens, and the always-on levers are what make it decide — but levers 4 and 5 put the body in front of it once the session is actually arranging work, which is the gap between "decided to use it" and "knows how to use it well".
Token effect
Six tool schemas plus their descriptions are injected into the system-prompt tool catalog, and — unless config.promptSection is false — one fixed sentence into the system prompt. Fixed cost; no per-task or data-dependent prompt growth.
KV Cache effect
Independent of the agent's request-context assembly. The tool schemas and the prompt section are static, so the prompt prefix is stable across requests; task-list content does not enter the prompt. A plugin version change (or toggling promptSection) replaces the section, or the tool schemas, and invalidates reuse.
Design notes
Tasks are session-independent by design. The record deliberately does not name the session that created it or last touched it, and no field ties an entry to a conversation. The library exists so that work hands off seamlessly: any session — a later one, a different preset, a subagent — picks an entry up and continues, and what it needs is what is true now and what to do next, both of which the record carries (progress, steps, nextStep). Recording an origin would make every entry an artifact of the conversation that produced it, which is the opposite of the point; it would also invite a later session to defer to an owner that no longer exists. This is why task_get leads with state rather than history, and why nothing has to be rewritten when a task outlives the session it started in.
Known Limitations and Deferred Work
No graph layout — the
dagsurface renders nodes as chips plus an edges list, not a positioned graph.Surface is opaque — the Host stores
surfaceas unvalidated JSON (z.record); malformed kinds fall back to "ignored" in the renderer rather than a load-time error.sync-profile.mjsis a local dev helper — it copies the built package into$DSH_HOME/profiles/<profile>/node_modules(profile defaults toweb) and warns when that profile'sdsh.profile.bundlesdoes not list the plugin. It is not published (excluded viafiles); usedsh plugin addfor installation.Job-status sync degrades, never blocks — a runtime exposing neither
jobs.events.subscribenorjobs.onJobsChangedkeeps the tools, the panel, and the HTTP API, and logsjobs service exposes no change feed; job status sync disabled.The prompt section cannot force the behaviour — descriptions and one sentence raise the odds that the model records its work; nothing in the harness compels it. A deployment that needs a guarantee should say so in its own persona or preset instructions, which outrank a plugin's contribution.
Skill updates are seeded, and can also be shadowed — the Host half writes
skills/task-ui/SKILL.mdinto<dshHome>/skills/task-ui/and keeps it current, but it will not clobber a copy the user edited: an edited copy is left alone and this version's text is written beside it asSKILL.md.new. More importantly, the seeded copy is only one of several possible roots, anddsh-skill-filesystemranks them (lowest wins): project.dsh100, project.agents200,customSkillDirs300,$DSH_HOME/skills400, user.agents500, and a package's own bundled copy last at 600. A deployment whosecustomSkillDirsholds its owntask-uicopy therefore shadows the seeded one — an agent preset copied from another machine can silently pin a months-old manual while the plugin's copy is current. The plugin cannot see or fix a shadowing copy; if the loaded skill looks stale, check those roots first.Blocked reasons have no field of their own —
dependsOncovers task-to-task blocking; a reason that is not another task ("waiting on an external service") has onlynextStepto live in.A
runningtask linked to a job is flagged, not corrected — the job registry is in-process, so ajobIdwritten before the running Host started cannot refer to anything alive. Both read paths say so:task_listappends[expired],task_getleads with awarning:line, and the panel colours the job line with--dsw-alias-state-warn-primary. The record is annotated rather than rewritten, because the plugin cannot know whether the work finished, was abandoned, or is simply unrecorded. The signal is the task's ownupdatedAtcompared against Host start (jobsexposes no call that enumerates every owner's jobs), so a write after boot clears the flag — a false negative if a session setsrunningby hand on a task that still carries an oldjobId.The client bundle is not byte-reproducible —
npm run build:clientemits the CSS-module class map in a different key order on every run, so the bundle hash changes even with identical sources. The key→hash pairs themselves are stable and nothing at runtime observes the order; the only casualty is comparing a local build against a published tarball by hash, which is whylib/client.jsnever matches its published copy byte for byte.
Acknowledgements
Inspired by HomeRail — its generative-UI (A2UI) idea of "the agent emits a bounded, whitelisted component tree that a host renders into a glanceable view" shaped this plugin's surface document and component catalog (section / metric / statusBadge / progress / table / list / timeline / dag / disclosure), as well as the principle of preferring built-in rendering over dumping raw logs.
