Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add dsh-suggest-ghost
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
Screenshots
README
dsh-suggest-ghost
English | 简体中文
Input prediction plugin for DeepSeek Harness Web: after each turn completes, one auxiliary LLM call predicts your next prompt and renders it as ghost text in the input box when the draft is empty; while you type, it completes from the current conversation history by prefix match. Tab accepts the whole suggestion, → accepts word by word.
What it is
A pure plugin mount (host listens to turn events + client-side DOM overlay) — no changes to any DSH core code. Two modes switch automatically:
- LLM next-suggestion (empty draft): after every completed turn, the last round of conversation is sanitized and sent to the suggestion model (inherits the main request route by default, no extra config) to produce "the sentence you are most likely to type next" — the same experience as Claude Code.
- History prefix completion (non-empty draft): zsh-autosuggestions style — finds prefix-matching entries in conversation history, scores by recency first, with in-session frequency and cross-session hotness as secondary signals, and renders the remaining part in gray. Full/half-width punctuation, whitespace, and case differences do not affect matching.
Ghost text is rendered as a DOM overlay approximation (follows the input's font and scroll), not depending on the official setGhost input capability, so it works on rc.6; keystrokes are not intercepted during IME composition.
Preview

① History prefix completion (non-empty draft) — type "list do" and the ghost completes the remainder of that historical message:

② LLM next-suggestion (empty draft, auto-predicts your next step after a turn ends):

Dark text = what you typed; gray = ghost suggestion. Tab accepts the whole suggestion, → accepts word by word. Ghost text follows the input's font and scroll rendering — it never overlaps or steals focus; if you don't like it, just ignore it and keep typing and it disappears — zero cost.
Installation
From npm (recommended — prebuilt, no approval needed):
dsh plugin --profile web add dsh-suggest-ghost
One-line install from GitHub (build artifacts are committed to the repo, no local build needed):
dsh plugin --profile web add "github:WuJiaoJue/dsh-suggest-ghost"
Or from source:
git clone https://github.com/WuJiaoJue/dsh-suggest-ghost.git
cd dsh-suggest-ghost && pnpm install && pnpm run build
dsh plugin --profile web add .
After installing, restart dsh web and hard-refresh the page with Ctrl+Shift+R. Works out of the box with no configuration required.
Configuration
All settings live in the Suggest ghost card in Settings → Plugins and take effect immediately after saving: card copy (field labels/hints, buttons, badges) follows the host DSH UI language (中文 / English) and updates in real time when the language is switched in Settings, without a refresh; hosts without a locale service fall back to Chinese.
| Group | Fields |
|---|---|
| LLM next-suggestion | enable toggle, max output tokens, max suggestion chars, reference turns, transcript char budget, timeout (ms), accept key, provider / model route (leave empty to inherit the main request) |
| History prefix completion | enable toggle, cross-session search, max history entries, min input chars, word-by-word accept |
| Hotness management | frequent cross-session phrases list: pin (exempt from eviction, always leads candidates), delete, manual add (pinned by default), clear all — takes effect immediately, no save needed |
You can also override by id in cordis.patch.yml (as initial values for the above):
- id: suggest-ghost
config:
maxInputBytes: 4096 # framed user prompt byte cap
maxOutputTokens: 512 # suggestion output token cap
timeoutMs: 60000 # auxiliary request deadline (ms)
maxRecentTurns: 1 # most recent completed turns sent to the suggestion model
maxTranscriptChars: 12000 # transcript char budget
maxSuggestionChars: 240 # visible suggestion char cap
acceptKey: Tab # accept shortcut
llmEnabled: true # LLM suggestion toggle
Security
- Only the last round of conversation is sent to the suggestion model, with common credentials auto-masked before sending; output is purified and unqualified replies are silently discarded
- Fully bounded end to end: input bytes / output tokens / timeout capped; re-entry guarded within the same turn, new turns invalidate stale generations, unloading aborts in-flight requests
Compatibility
Verified on five kernel generations (0.1.5, 0.1.7 and 0.2.0 re-measured 2026-09-29; the rest 2026-09-07):
| DSH kernel | peer gate | host entry link | runtime symbols | isolated profile | settings page |
|---|---|---|---|---|---|
0.1.1-rc.2 |
— | ✅ | ✅ 8/8 | ✅ | ✅ |
0.1.2-rc.1 |
— | ✅ | ✅ 8/8 | ✅ | ✅ |
0.1.5-rc.3 |
— | ✅ | ✅ 10/10 | ✅ | ✅ |
0.1.7-rc.2 |
— | ✅ | ✅ 10/10 | ✅ | ✅ |
0.2.0-rc.2 |
✅ | ✅ | ✅ 10/10 | ✅ | ✅ |
Peer enumeration, and the hard gate that starts at 0.2.0
Peer deps are written as an explicit generation list (^0.1.1-rc.2 || ^0.1.2-rc.1 || … || ^0.2.0-rc.2) rather than >=0.1.1-rc.2: node-semver excludes prereleases from every range unless a comparator carries the same [major.minor.patch] tuple, so ^0.1.1-rc.2 is false for 0.1.2-rc.1, and so is * — there is no range form that spans generations. Every time upstream ships a new rc generation, add another entry here.
At 0.2.0 this stopped being an install-time hint and became a load-time hard rejection. While composing a profile the kernel checks each peer with semver.satisfies(runtimeVersion, range, { includePrerelease: true }) and skips the whole bundle on a miss:
dsh: skipping profile bundle "dsh-suggest-ghost": Error: Plugin … is incompatible
with dsh 0.2.0-rc.2: peerDependencies {…}
Two details worth remembering:
- the gate uses
includePrerelease: true, so^0.2.0(without the rc) is false for0.2.0-rc.2— the entry must be written as^0.2.0-rc.2; dsh plugin allow-versiongrants an exact-version exemption, which bypasses the check rather than fixing it. The real fix is still another enumeration entry.
The 0.1.7 settings rework (the only substantial source change)
0.1.7 removed the whole settings.yaml system, so the plugin's data channel had to move:
0.1.5 |
0.1.7+ |
|
|---|---|---|
| User-editable options | ctx.settings.register(ns, schema, …) |
per-field .volatile() in Config; the kernel projects the plugin's own Config schema into a form, ns = Loader entry id |
| host → client | _push on a SettingsScope |
ctx.settings.update(ns, {_push}) (_push marked volatile) |
| client reads | settingsScope.bind(ns).get() |
remote.settings.describe() (first read + event-driven re-read) |
| client → host | scope.set('_ops', …) |
remote.settings.mutate(ns, [{op:'set',path:['_ops'],…}]) |
| change signal (host) | scope.watch |
settings/document-updated(ns, revision) event |
| change signal (client) | scope.subscribe |
remote.$on('settings/document-updated') |
The payload shape is unchanged (the _push / _ops JSON, including the suggestionSessionId / historySessionId ownership guards) — only the carrier moved, so the client's parsing and ownership logic needed no rewrite.
Three details that only surface when you try them:
Configmust not be marked.volatile()as a whole, andz.object({…}).volatile().dictmust not be spread back in — the former exposes the strategy parameters as form fields too, the latter drops each field's ownmetaduring flattening and makes the entire form invisible. Mark per field.- A volatile field resolves to a reference (cosmokit's
Volatile<T>), not a bare value, so reads must go through.get().flattenConfig()flattens those references into plain values so the rest of the plugin is generation-agnostic; it also accepts bare values, so0.1.5and earlier keep working. provider/modelchanged fromz.string().min(1)to.default(''): once they are form fields, being required means the plugin "cannot start until filled in", while the intended meaning of empty is "inherit the main request's route".
Other cross-generation choices
- No
settingsNamespace(): the helper was removed in0.1.2, and importing it at runtime makes the whole module fail to link. It only validated and returned the branded string unchanged, so the code asserts the literal instead ('suggest-ghost' as SettingsNamespace). - No
deepFreezeimport:0.1.1exports it fromdsh-llm,0.1.2moved it into the newdsh-util-valuesand stopped re-exporting it — and that package does not exist on0.1.1, so switching the import source would only break the other generation.src/generate.tsships an equivalent implementation (iterative traversal, cycle-safe, skipsAbortSignal). - The client bundle takes no types from
@deepseek-ai/dsh-client-runtime: that package is0.1.1-only and was split out in0.1.2.lib/client.jswas measured to have zero kernel imports (onlyreact), so it is generation-independent; the minimalctx.remoteface is declared client-side (seeRemoteLikeinsrc/client/index.ts). source.kindis'user', not'plugin':0.1.7dropped thepluginkind and theschedulekind it added does not exist on0.1.5, so the two generations have no overlap. The field only builds a one-shot LLM request payload; it is not persisted or projected.
Known trade-offs
- Old settings do migrate: a value tweaked on the
0.1.5settings page (e.g.historyMaxEntries) was measured restoring as-is on0.2.0— the user layer stays in the profile document. What is not persisted is the in-memory volatile reference, so after a restart you read the persisted profile value rather than last run's memory value. - No polling on the client: what
0.1.7removed along withsettingsScopeis onlyscope.subscribe. The kernel has a separate Host→client event channel —ctx.remote.$on('settings/document-updated'), on dsh-api-remotes' forwarding allowlist (modeemit). The kernel's own settings page drivesSettingsDescribeMirroroff exactly that. This plugin subscribes the same way, sodescribe()is issued only when the host actually pushes_push, plus once at startup and once perconnection/reset.
How to reproduce: build an isolated profile for the kernel under test (point $DSH_HOME at a temp directory and hardlink its node_modules to that kernel tree with cp -al, so upward resolution cannot leak into the other generation), then check manifest composition with dsh --profile <p> --dump-config — on 0.2.0 this is the step that surfaces the peer-gate rejection — and watch plugin loading via dsh --profile <p> "say hi" and dsh --profile <p> -- --no-open --port <p>. Headless runs stop at MISSING_CREDENTIAL (the temp home has no credentials) — a point after plugins have loaded.
Development
src/index.ts host entry: turn/end(completed) → bounded suggestion generation
src/coldstart.ts cold-start semantics (pure): ring seeding from the log, turn-based staleness, _push read-back, suggestion tracking
src/generate.ts transcript extraction → sanitization → ctx.llm.stream → purification
src/transcript.ts pure transcript logic: char + UTF-8 byte dual-budget trimming (unit-testable)
src/sanitize.ts sanitize / purify / semantic filter / truncation (pure functions)
src/settings.ts settings namespace + host→client push (_push) and client→host ops channel (_ops, tail-write coalescing)
src/hotness.ts cross-session hotness table (incremental dedup, min-heap eviction, bounded memory, pin/manage APIs)
src/hotness-store.ts hotness persistence (storageDomain unit, turn-boundary coalesced writes, restore-on-restart, ops application)
src/projection.ts suggestGhost projection last-wins fold
src/client/ ghost rendering, history matching, word splitting, shortcuts, settings card
scripts/ smoke tests and session log replay
Cold-start semantics (why it works right after a restart)
"No data after a restart" is a misreading: history lives in the session log, and the host re-sends the suggestion after checking it semantically. The old design used time as the criterion (invalidate on restart, seed only when an event arrives), which left two gaps. It now uses semantics:
- The history ring is a cache of the session log: on a pull miss it is recomputed from the log (
ringFor), so there is no "seeding moment" and therefore no race where a pull arrives before the first event. Live sessions maintain it incrementally. - A suggestion is valid iff its turn is still the session's last completed turn (
suggestionIsCurrent). The host runs that check on every state assembly: still valid → restored as-is (your last suggestion is there when the page opens, no need to wait for a new turn); provably stale → cleared. - All three push paths (pull reply / turn end / hotness-restore re-push) share one state assembler
(
statePushOf), and the client pulls at the single point where a session binding succeeds.
Difference from 0.1.5: there
_pushlanded insettings.yaml, so the previous suggestion could be read back after a restart. On0.1.7+volatile fields are not persisted, so the last suggestion must be regenerated after a restart — it returns as soon as the next turn completes. History prefix completion and the hotness panel keep working throughout, since they read the session log and the storage domain rather than a volatile field. Do not confuse this with your settings values, which do live in the profile document and do migrate (see Known trade-offs).
pnpm run build # tsc compiles host + esbuild bundles client → lib/
pnpm run test:smoke # pure-function smoke tests (incl. hotness persistence and cold-start semantics)
pnpm run test:e2e # end-to-end over the real stacks: hotness persist→restore; cold-start reconciliation/pull
pnpm run replay # replay the completion pipeline with real session logs
Known limitations
- The cross-session hotness table is now persisted (host storage domain, lands in
~/.dsh/storages/suggest_ghost_hotness.json): frequencies survive restarts instead of accumulating from zero; on hosts without the storage domain (older versions) it automatically degrades to in-memory only - After a restart, history completion and the previous suggestion are available immediately (the criterion is semantic, not restart timing); a suggestion for a new turn still requires that turn to finish — that is inherent to "predict the next prompt", not a startup delay
- The management panel lists and filters the pushed top-K snapshot (50 entries by default; "N total" shows the full count); host-side search beyond the top-K is not implemented
- Delete only clears the current tally — typing the same text again re-counts; pin is the "never evicted" semantic
- LLM suggestions cover only the current session; to include text from other sessions as candidates, enable "cross-session search"
- Every completed turn triggers one suggestion model call (regardless of whether the input box has content); disable it in settings to save tokens if not needed
License
MIT © wujue. The security and generation pipelines are implemented with reference to dsh-suggest-prompt (MIT).
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.