@dshkit/design
v0.1.0
Published
A design surface for DeepSeek Harness: the agent writes a self-contained index.html into the directory it is already working in, and this puts it on the screen beside the conversation.
Maintainers
Readme
dshkit-design
A design surface that lives where the agent already is — and leaves its output where the coding surface can pick it up.
Describe the interface you want. The agent writes a self-contained index.html
into the workspace directory. This plugin puts it on the screen, beside the
conversation, without you leaving.
Status: the host and browser halves are implemented. The browser registers both as a public
dshkit.workbench.v1consumer and as ashell.overlayfallback for hosts without the carrier; it never renders both doorways at once. Runpnpm checkfor the current type/test/build result.
Why this repository is separate
It is the first of a family. There will be others — a coding surface next — and
each one is a demonstration-grade external project: its own repository, its
own release, depending on dshkit the way a
stranger would.
That separation is the point, not a packaging preference. This package consumes
the frozen, types-only @dshkit/sdk; it does not import the carrier's internal
registries or manufacture its branded identities. The host mints task and output
ids, holds task state while the workbench is inactive, admits state transitions,
and stamps notification source and time.
The original implementation deliberately lived only on shell.overlay while
the seat contract was still closed. Its measured costs informed that contract.
Now this repository is the first external consumer of the public seat. The
old overlay face remains only as a no-carrier fallback: when the workbench face
mounts it claims presence and the fallback renders nothing, so one plugin never
puts two doorways on one screen.
The shape, and the one boundary that carries it
The artifact is a design draft, not an implementation: one file, no build step, no network dependency. Everything else follows from that line — a single self-contained document is what makes a sandboxed preview possible at all, and what keeps this surface from turning into a second frontend toolchain.
The workbench itself is small on purpose: a sandboxed frame, a few viewport widths, a way to get the file out. The work is the agent's. This is the window onto it.
The part that is specific to dsh, and it is load-bearing
The obvious way to build this — the way the reference implementation this is
modelled on does it — is to have the browser read the file. In dsh the browser
half cannot. The client-facing API surface has no readFile: it can list
directories (host.listDirectory, and only under the browse capability),
never file contents.
⚠️ Corrected before anything was built on it. That paragraph was first written as "the browser cannot reach the filesystem", and that is false: the browser already knows the directory — session summaries carry
cwd, and the shipped workspace UI reads it every day. What it cannot get is contents.The distinction is not pedantry, it decides the shape. From "the host completes what the browser lacks", letting the browser send the path it already knows reads as the natural design. From the reason that actually survives — knowing a path is not authority to name one — it cannot be written down at all. The host half is not keeping a secret; it holds the only authority.
So this plugin needs a host half, and the two halves have to talk. That path exists and has been measured end to end — a third-party dual-half plugin registering its own endpoint and calling it from the browser, with a per-run nonce proving the answer came from the host and a deliberately-undeclared neighbour method answering 404 as the control.
Which makes this more than a demo. dshkit's stated direction is that what each surface produces flows to the others, and that the carrier of that flow is the workspace directory — a real path shared by every session under it. That claim has been checked against the architecture and has never been walked. This is the first thing that walks it: the agent writes a file, the host half reads the directory it was already working in, the browser half shows it, and a coding session in the same workspace opens the same file with no bus in between.
Settled boundaries
- What the host half is allowed to read. Taking a path from the browser is
the wrong shape; upstream faced the same choice and took an id instead,
resolving the path itself. Ours is narrower than "any file": the host mints
ids only by enumerating
.design/*.htmlunder the selected session's cwd. - How long work survives. The seated workbench uses host-owned tasks: work continues while the pane is inactive in the same dsh run, and exposes cancel/retry/recover according to the host state. Cross-process persistence is not claimed.
- How completion is announced. A completed run submits through the pane's notification edge and targets the last host-minted output, so a click lands on the concrete draft. A missing target becomes an explanatory state, not a blank frame.
- Whether the preview polls or is told. It does neither: the visible panel refreshes on open, on an explicit refresh, and when a seated workbench becomes visible again. Background review uses a non-rendering request path, so it does not steal the user's current preview.
What replaces the first host half
Two endpoints, both taking a session lookup parameter and neither declaring a
scope:
list(session) → { id, label, bytes, modifiedAt }[]
read(session, id) → the documentThe ids are minted here. The browser has no way to construct one — every id
it holds came back from a list() this half produced. So "read something that
was never enumerated" is not a request that gets refused, it is a sentence that
cannot be formed. That is a smaller attack surface than validating a name: what
has to be right is the enumeration, not a guard.
It is also not an invention. Upstream ships the same shape — agentPreset.list
and agentPreset.read, where the read takes an id and the host resolves it
itself — so this is a copy of a decision already made, not a new one.
Three things are settled and worth stating because each was nearly got wrong:
- The directory comes from the session,
session.header.cwd, the way upstream's own file search resolves it.cwdis optional there, and when it is absent the answer is a refusal that says so — not a fallback to the process's directory, which would quietly enumerate somewhere the user never meant. - No
scopeon the descriptor. The obvious model to copy declaresscope: { context: 'agent' }, which lets the browser omit the id. A neighbour onshell.overlayis a root context, and the client-side identity for a root context isundefined— so every call would throw, while the error text sends whoever reads it looking at cordis contexts. The session id is passed explicitly instead. sandbox="allow-scripts"withoutallow-same-origin. Granting both is granting neither: a document with both can remove the attribute itself.
Not in the first version, deliberately: no write (the agent has write tools, and a second writer is a second authority), no watch (the panel cannot be told when it is invisible, so a watcher would only spin in the background), and no relaxation of the sandbox.
What being a neighbour costs, measured
The list below is the point of this repository. It is filled in from admission runs against a real harness, never from reading the carrier's source — a cost nobody has paid is not evidence.
First reading, 2026-08-20, one boot with the host driven into an approval wait:
[FAIL] announces-the-wait — the host was waiting (approval) and nothing on the
declared surface changed — an agent stopped there and this screen says
so nowhere⛔ That reading was withdrawn and then re-taken. The instrument that produced
it drove the wait by writing pendingInteraction into the host's session list
store — which is a projection rebuilt wholesale from the session manager on
every notification, so the stimulus could be erased before the screen was read.
A surface is quiet when nothing is waiting, and the check called that quiet
"an agent stopped there and this screen says so nowhere". The words above are
therefore words the instrument could produce on its own, and the qualifier
originally recorded here — deterministic, not intermittent — was measured with
that instrument and is retracted.
Re-taken 2026-08-20 with the stimulus injected into the manager itself, where a projection carries it instead of erasing it, and with each of the three readings now carrying the host store's own answer taken in the same call:
[FAIL] announces-the-wait — the host was waiting (approval) and nothing on the
declared surface changed
[attend] host store while the surface was read: 1 session(s) waitingThree runs out of three, every one of them with a session genuinely waiting at the moment the screen was read. The finding stands, and the difference from the first version is not the verdict but that this one can tell "the surface said nothing" from "there was nothing to say".
A neighbour has none of the machinery that answers this: reading
pendingInteraction across every session, choosing the most consequential of the
three statuses, and announcing without seizing. The carrier does all of it and a
neighbour inherits none of it, so every demonstration project in this family
will fail this check on its first run, and each one will either rebuild that
machinery or ship a surface that can hide an agent waiting on the user.
⚠️ Read with its shape: this is the neighbour driven the way the carrier is
driven — panel opened, no session staged. A run that also stages a session moves
the panel's own contents for reasons that have nothing to do with the wait, and
the check correctly declines to attribute that (unknown, not fail).
Everything else the same run measured is green: it installs, boots, seats without shadowing anyone, crashes nothing, and carries a label that survives minification.
⛔ In-place correction, 2026-08-20. The sentence above used to also say gives the screen back when asked, and that run had not measured it. No runner had ever passed
--exit, soreturns-the-screencame backunknown— "no way out was declared, so whether the user can get back out of this app is unanswered" — and it is a BLOCKING check. For a full-screen app the hole was wider than one line:takes-no-protected-pointanswersn/aand defers in those words, see returns-the-screen, which is the question that has an answer. So whether the host got its screen back had no reading at all behind it, under a paragraph that said it did.The way out had been on the close button the whole time (
data-dshkit-design-exit,src/client/Stage.tsx). Declared and measured, 2026-08-20:[ok] returns-the-screen — after [data-dshkit-design-exit] the host has every region it needs back (1 body-level node(s) remain, see portal-disclosed)⚡ Not a ledger entry, and that is the point of writing it down. A neighbour pays nothing here; what was wrong was the instrument's shape and a sentence that ran ahead of it. This ledger's rule was always "only what was run, never read off the carrier's source" — this is the same rule failing in the direction nobody watches, where prose fills a gap a reading left open.
The shape is no longer carried in prose alone.
dshkit/scripts/measure-neighbour-cost.mjsholds it, prints it above every reading, and refuses to let a ledger entry be written from a run in which a blocking check applied and was never asked (exit 2, and it names the instrument rather than the neighbour).n/ais deliberately left alone: it means no claim was made, which is an answer.
Second entry, 2026-08-20 — paid while standing tsc up over src/, not
inferred from anything:
src/client/Stage.tsx(26,20): error TS2307: Cannot find module
'./stage.module.css' or its corresponding type declarations.@dshkit/tsdown-client turns a CSS Module into a class map at factory time, so
the build works and the type checker does not. The ambient
declare module '*.module.css' that makes it check is a file the carrier
already has and that a package outside that repository cannot import: the preset
publishes the build, not the types the build implies. This repository therefore
carries its own copy — and so will every neighbour after it.
Small, and recorded precisely because it is small. The case for opening the seat contract will not be one expensive thing; it will be a list of cheap ones that every neighbour pays over again.
How the browser half is checked, and what the check cannot see
pnpm run check type-checks, runs the tests, and builds. The tests that drive
the panel render the real Stage into a real document and assert against the
DOM — never against DraftsStore's snapshot, and never against a sentence
lifted out of the component into a helper. That is not a style preference. The
store has set loading and reading since the first version of this plugin and
nothing rendered them: a test asserting status === 'loading' would have been
green for the entire life of the defect, because the lie lived in the gap
between the store and the screen.
Two pieces of scaffolding exist only so Node can get there. tests/support/
transpiles TSX (Node strips types but has never handled JSX) and installs a
jsdom document before React is evaluated. Both files say what they cannot see,
and it is worth repeating here:
- no bundle. Modules are transpiled one at a time;
tsdownis what ships. A bundler-only failure is invisible to these tests, which is why the build stays inpnpm run check. - no layout. jsdom lays nothing out, so
position,z-indexandpointer-events— the three properties that have actually bitten this plugin on a real overlay layer — are unobservable. Nothing here is evidence about them; those are measured on a real boot.
Run either half of the panel's own criteria on its own:
node --import ./tests/support/register.mjs --test tests/stage-progress.test.mjs
node --import ./tests/support/register.mjs --test tests/stage-latest-intent.test.mjsThe first pins what the panel says while it is waiting. The second pins who owns
the screen when two answers are in the air: the most recently issued request
owns every visible field, and a response that no longer holds the ticket writes
none of them. The rule is stated where it is implemented, in
src/client/drafts.ts.
Conventions
Source, comments and commit messages are English, matching dshkit. Chinese
lives in the sibling design repository, not here.
License
MIT
Not affiliated with DeepSeek.
dshkitis an independent community project.
