DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

Qa Surface — DSH Plugin for DeepSeek Harness
DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in
← Plugins

@yadsh/dsh-qa-surface

Qa Surface

A focused end-user QA surface backed by native DeepSeek Harness sessions

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add @yadsh/dsh-qa-surface@0.9.0
READMECompatibilityVersions

Compatibility and provenance

Qa Surface is published as @yadsh/dsh-qa-surface and currently resolves to version 0.9.0. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
npm
Registry updated
9/20/2026

Versions

0.9.0stable
9/18/2026
0.8.0stable
9/17/2026
0.7.4stable
9/16/2026
Show 12 more versionsCollapse versions
0.7.3stable
9/16/2026
0.7.2stable
9/16/2026
0.7.1stable
9/16/2026
0.7.0stable
9/16/2026
0.6.1stable
9/15/2026
0.6.0stable
9/15/2026
0.5.0stable
9/14/2026
0.4.0stable
9/13/2026
0.3.0stable
9/12/2026
0.2.0stable
9/10/2026
0.1.1stable
9/6/2026
0.1.0stable
9/6/2026

Related plugins

Loading related plugins…

Latest
0.9.0
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
7.6 MB
Files
677
Surface
web
License
MIT
Source
npm
GitHub
★ 2
Weekly downloads
1,686
Security scan
✓ v0.9.0 scan passed
Last push
9/18/2026
View source ↗Project homepage ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue

Related plugins

More verified plugins in ui-customization.

Web App@deepseek-ai/dsh-web-appThe dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)Experimental Agent Team Web Profile@deepseek-ai/dsh-experimental-agent-team-web-profileExperimental Web profile layer for Agent Teams Remote and UI pluginsClient Ui Task Board@linxin666/dsh-client-ui-task-boardHost-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.Pet@linxin666/dsh-petMulti-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score

README

@yadsh/dsh-qa-surface

A focused, responsive QA/chat page for DeepSeek Harness at /qa. It replaces the presentation, not the harness: prompts, streaming, tools, skills, MCP, memory, persistence, permissions and telemetry continue through the native DSH Session and Agent Loop.

What it does

  • contributes one root-scoped entry to the additive shell.overlay slot;
  • stays invisible outside the configured route;
  • creates or restores one real DSH Session;
  • renders only user text, assistant-visible text and safe status messages;
  • supports streaming, Stop, optional New chat, safe Markdown, copy actions and a responsive first-party-style conversation layout;
  • renders assistant Markdown with this plugin's own GFM grammar — headings through ######, nested and task lists, tables, quotes, images, autolinks, reference links — inside an HTML-free renderer, and relays the host theme's own typography and syntax colors (--dsw-font-markdown-*, --dsw-alias-markdown-*, --shiki-token-*) so a fenced block here reads like one in DSH's own transcript, in either theme and at any font-size preference. A fence carries the language banner, a copy button and a built-in highlighter for the languages answers use; an unknown or absent language stays plain monospace;
  • optionally shows a minimal per-browser chat-history sidebar (ui.showSessionList) whose switching re-runs policy attestation;
  • blocks unsupported approvals/questions instead of auto-approving them, and can park them for the operator to answer (interaction.approvals: interactive, interaction.questions: interactive) instead of refusing or stalling on a card the QA view cannot show; a parked question takes the composer's place until it is answered or its turn ends;
  • pins locked sessions to the configured read-only or isolated workspace-write policy plus approval=never before Send is enabled;
  • applies a Host-side tool allow-list plus a monotonic execution guard;
  • separates account authorization (admin/user) from the QA agent's active subrole, with server-owned Common, per-role Tools and Skills, immutable session snapshots, and a dedicated administration page at /qa/admin;
  • attaches its own QA tool catalog per agent only after the activation skill loads (tools.dynamicActivation), keeping every QA schema out of the initial request and restoring the catalog on resume from the session's own journal;
  • optionally gates the surface behind email + password accounts (accounts.enabled) with server-side session ownership, a first-login migration of the browser's existing chats, a qa-accounts management CLI (list/add/set-password/set-role/disable/revoke), and a coarse honest boundary: accounts identify QA users, they do not fence the harness root;
  • gives each account a Настройки dialog — profile, starter messages, general, and personal skills: ordinary Agent Skills stored as SKILL.md in the account's own directory (accounts.skills), edited with a catalog, an invocation-flag form, a Markdown body, a tool picker over the deployment's registry, and a preview of the exact file a save writes. Skills reach the model through a provider this plugin registers instead of the filesystem one, so no account can see another's, and allowed-tools is stored as declared but never grants anything the session does not already allow;
  • optionally redirects non-loopback hostnames from the harness root into the QA route (entry.redirectNonLoopback), keeping the operator's localhost harness UI untouched;
  • ships an operator settings card (Settings → Plugins → plugin configuration → «Помощник QA») that edits the qa-surface namespace in place — route, branding, session, interface, lockdown, accounts, sources, attachments, embedding — and reports the configuration the running Host resolved;
  • no longer owns the document pipeline: document_create, document_to_markdown, document_from_url, document_convert and document_inspect come from @yadsh/dsh-documents, which a QA chat reaches exactly as before — same tool names, same allow-list entry, same artifact layout. What moved with it is the configuration: the pipeline is configured in that plugin's own documents namespace and card (QA_DOCUMENTS_* environment variables became DSH_DOCUMENTS_*), and a leftover documents: section under qa-surface is ignored with a documents.moved warning in the Host log;
  • uses the existing same-origin DSH connection and trust boundary.

It does not add another HTTP server, provider proxy, permissive CORS rule, or custom session persistence.

Install

Install the package where the DSH Host can resolve it, then compose its Loader row. The included cordis.patch.yml is a minimal example:

- insert:
    - id: dsh-qa-surface
      name: "@yadsh/dsh-qa-surface"
      config:
        enabled: true
        route:
          path: /qa
          matchChildren: true

Some DSH patch operations replace a row's complete config instead of deeply merging it. When editing an existing row, provide every value that deployment needs.

Tested DSH releases serve unknown frontend paths as 404 rather than falling back to index.html. The Host half therefore claims only the configured QA navigation path and redirects it through the canonical / document with a short-lived query marker. The browser restores the requested /qa URL with history.replaceState before mounting the overlay. No second server, duplicate HTML document, permissive route, or unauthenticated config endpoint is added.

Configure

Configuration is registered under the Host settings namespace qa-surface. Composition values form the base layer; normal DSH user settings can override them when the deployment provides writable settings.

The browser half carries a settings card for that namespace: Settings → Plugins → plugin configuration → «Помощник QA». It writes the user layer of qa-surface — so every change is revertible through the card's own reset — and shows the configuration the running Host resolved next to it. Combinations the Host refuses are either written together in one mutation (a provider with its model, per-user workspaces with the workspace-write sandbox) or disabled with the reason stated. The card renders only where the settings namespace is readable, which the DSH gateway pins to loopback; a browser served over the LAN reads the same configuration read-only through qaSurface/describe on the QA page itself.

config:
  enabled: true
  route:
    path: /qa
    matchChildren: true
  branding:
    title: Внутренний помощник
    subtitle: Отвечает на вопросы о внутренней платформе
    welcomeMessage: Чем могу помочь?
    placeholder: Задайте вопрос…
    logoUrl: null
  session:
    # Pin every chat to a directory (optional, one of):
    # cwd: "D:/qa-docs"        # direct absolute directory pin
    # workspaceId: "<uuid>"    # or a registered DSH workspace
    policy: browser-persistent
    storageKey: dsh-qa-surface.session
    workspaceId: company-knowledge
    fixedSessionId: null
    agentPreset: qa-assistant
    provider: null
    model: null
    reasoningEffort: null
  ui:
    showHeader: true
    showReset: false
    showStop: true
    showTimestamps: false
    showToolActivity: false
    showReasoning: false
    renderMarkdown: true
    minContentWidth: 650
    showSessionList: false
  accounts:
    enabled: false
    allowRegistration: true
    sessionTtlDays: 30
    # Login/registration attempts accepted per rolling minute, store-wide
    # (optional). The limit backs the password checks; raise it only when a
    # shared kiosk genuinely needs the headroom.
    maxAuthAttemptsPerMinute: 30
    showOtherUsersChats: false
    perUserWorkspace: false
    profile:
      enabled: true
      inject: true
      identities: [] # e.g. [{ key: jira, label: Jira }]
      instructionsMaxLength: 2000
    # Per-account starter buttons above an empty composer (optional).
    starters:
      enabled: true
  suggestedQuestions:
    - Как запросить доступ?
    - Где лежит инструкция?
  # Phrases the running indicator cycles through (optional). An empty list
  # restores the built-in ones.
  thinkingPhrases:
    - Уточняю по регламенту…
    - Сверяюсь с инструкцией…
  # What a visitor may attach (optional). Files are staged on the Host and
  # read from the stored copy, so keep `read` in lockdown.toolPolicy.allow.
  attachments:
    textFiles: true
    pastedTextLines: 200
    maxFileBytes: 10485760
    maxPending: 8
    extensions: [md, txt, log, json, yaml, csv, sql]
  interaction:
    # blocked: refuse a composed gate's `ask` with the QA reason.
    # interactive: park it over the composer for the operator to answer.
    approvals: blocked
    # unsupported: refuse ask_user_question with an actionable reason.
    # interactive (or enabled): park the request over the composer as an
    # answerable form, which owns the composer until it is answered or the
    # turn ends.
    questions: unsupported
  # Root-page entry behavior (optional; see "Serving the QA rig over the LAN").
  entry:
    # Inject the root → /qa redirect for non-loopback hostnames.
    redirectNonLoopback: true
    # Let the /qa route run the one-time ?token= host-cookie exchange itself,
    # so transparent entry works without the deploy proxy.
    cookieBootstrap: true
  lockdown:
    enabled: true
    enforceFixedAgentPreset: true
    enforceFixedWorkspace: true
    enforceFixedModel: true
    sandboxMode: read-only
    approvalPolicy: never
    permissionPreset: qa-read-only
    allowPermissionChanges: false
    allowSlashCommands: false
    allowSettingsMutation: false
    allowSessionReset: false
    allowSessionRename: false
    allowSessionDelete: false
    allowArbitrarySessionOpen: false
    toolPolicy:
      mode: allow-list
      allow: []
  sources:
    enabled: true
    collect:
      parentAgent: true
      subagents: true
      persistTurnEvent: true
    display:
      sidebar: true
      footer: true
      groupByKind: true
      showDiscovered: false
      showOriginBadges: false
      maxInitiallyVisiblePerGroup: 8
    webSearch:
      promoteSearchResultsWithoutFetch: true
      maxPromotedPerSearch: 5
    dedupe:
      normalizeUrls: true
      stripTrackingParams: true
      mergeFileRanges: true
    filePreview:
      enabled: true
      markdownRenderedByDefault: true
      allowRawToggle: true
      maxBytes: 2000000
      maxMarkdownRenderBytes: 1000000
    subagents:
      inheritSources: true
      enableReportToolFallback: true
      markIncompleteOpaqueRuns: true
      validateReportedSources: true
    legacy:
      parseAssistantSourcesBlock: false
  # QA tool delivery (optional). The plugin's own tool catalog stays out of the
  # model request until the activation skill has actually been loaded.
  tools:
    dynamicActivation: true
    activationSkill: qa-surface
    activationMode: all
    # Presets whose sessions may unlock the catalog. Empty leaves the gate open:
    # only do that when this Host serves one agent composition.
    activationPresets: []

workspaceId is recommended for a deterministic assistant. Without it, DSH uses the Host's normal default working directory. Put the system prompt, tools, skills, knowledge connections and permission policy in agentPreset, not in this UI plugin.

Dynamic QA tools

A QA deployment tends to grow a large toolset, and attaching all of it at boot puts every schema into every request — including the first small talk of a chat that will never use them. tools.dynamicActivation (the default) inverts that: the plugin's catalog is registered into the agent's own scope only after the model successfully loads tools.activationSkill, and qa_tools_selfcheck reports the resulting state.

The trigger is the authoritative result of the built-in skill tool, not the model's attempt, not a keyword in the transcript, and not a coincidentally matching skill description. A failed or refused load activates nothing, loading an unrelated skill activates nothing, and loading the same skill again is a no-op. If one tool fails to register, the whole attempt is unwound and the agent stays inactive — a half-attached surface would leave the model with a tool it cannot rely on. Registrations live exactly as long as the agent that owns them, so disposal and plugin unload leave no scoped tool behind.

A resumed chat is restored from its own journal: the successful skill load is already recorded there as a standard tool/call/tool/result pair, so the current catalog is re-attached before the first model step. The plugin appends no session event of its own — an unknown event type without an ignorable marker makes the whole log unreadable to a harness that does not mount this plugin.

Set tools.activationPresets to the preset your QA surface pins (qa-research in the deploy kit) in any Host that composes more than one agent type. With an empty list the catalog is reachable by any agent that loads a skill of the same name. tools.dynamicActivation: false restores the always-on behaviour and attaches the catalog to every managed agent at creation — useful for a deployment that would rather debug the tool surface than the trigger.

These tools are not lockdown.toolPolicy.allow entries, and cannot be: that list is validated against the mounted catalog at attestation time, and a tool that only appears later would fail the check. The QA execution guard authorizes exactly the names the activation manager reports for the calling agent, so a dynamically attached tool gets the same scrutiny as an allow-listed one. Tool visibility is not an authorization boundary — a QA tool that writes must still enforce its own permissions.

Writable per-user research space

Set accounts.perUserWorkspace: true only together with accounts, a registered session.workspaceId, lockdown.enforceFixedWorkspace: true, and a workspace-write + never permission preset:

session:
  workspaceId: "<registered-workspace-uuid>"
accounts:
  enabled: true
  perUserWorkspace: true
lockdown:
  enabled: true
  enforceFixedWorkspace: true
  sandboxMode: workspace-write
  approvalPolicy: never
  permissionPreset: qa-workspace-write
  toolPolicy:
    mode: allow-list
    allow:
      [
        read,
        read_image,
        glob,
        grep,
        write,
        edit,
        web_search,
        web_fetch,
        dsh_git_context,
        dsh_git_history,
        dsh_git_show,
        dsh_git_blame,
      ]
  sharedReadOnlyRoots:
    - E:/qa-assistant/workspaces/docs
    - E:/qa-assistant/workspaces/code

The Host resolves that Workspace record's path and creates <workspace>/.qa-users/<account UUID> with private Unix directory mode. It passes the child as session cwd but deliberately does not register or attach it as another DSH Workspace. Chats therefore remain ordinary entries in the global DSH session list rather than creating one Workspace row per account.

That choice has one visible consequence: in the host's workspace browser these chats sit under Ungrouped. DSH grants Workspace membership only to a session whose stored cwd IS the Workspace path - Workspace.attachSession compares the two after realpath, and the browser derives its groups from workspace.sessionIds alone - so a per-account child directory can never be a member, and no later action can make it one: the contract has no attach or membership request for an existing session, and dragging a session never crosses groups. Registering a Workspace per account directory would group them, at the cost of putting every visitor's scratch root into the operator's global workspace registry; this plugin does not do that.

Chats left outside every workspace for a repairable reason - created while the deployment pinned session.cwd, or through workspaceId with the same directory spelled differently (E:/base against E:\base) - can be adopted while DSH is stopped:

qa-attach-sessions                    # dry run against $DSH_HOME
qa-attach-sessions --write            # adopt; registry backed up first

The command adopts only sessions whose canonical cwd IS a registered Workspace path, prepends them newest first, and refuses anything below a Workspace path, because the Host drops those from membership again on the next read. Per-user chats are therefore never touched.

The boundary combines DSH workspace-write with a Host tool guard for both read and write paths, canonicalizes existing ancestors to reject symlink escapes, propagates the root to subagent sessions, rejects shell/process/LSP escape hatches, permits filesystem reads in explicitly configured shared read-only roots, and leaves repository selection to the separately configured read-only Git plugin. Writes remain confined to the account directory. The guard limits one model-controlled write to 10 MiB, and limits an account directory to 256 MiB. web_fetch plus write is the intended bounded research-download path; there is no unrestricted URL-to-disk or shell downloader. Account directories are persistent scratch space and are not deleted automatically.

For the layout above, configure @yadsh/dsh-git-readonly separately with repositoryRoots: ["E:/qa-assistant/workspaces/code"]. That plugin is the single repository-selection authority and exposes no mutating Git operation.

Model override is opt-in: provider and model must be set together. Slash commands are rejected as plain QA input. Reasoning and tool details remain hidden by default. Enabling ui.showReasoning and ui.showToolActivity adds a turn-scoped work disclosure: it stays open while the assistant is working, then collapses to Worked for ... before the final answer. Tool capability is still controlled exclusively by lockdown.toolPolicy.allow; the display flags do not grant tools.

User profile

Clicking the account name in the sidebar footer opens the signed-in user's profile: full name, one handle per external system the deployment declares, and free-form instructions about how they want answers. The Host hands both to the QA agent as a note in the conversation, so "покажи мои задачи" resolves to a tracker lookup with the right login instead of a question. Because a QA preset can declare its persona the complete system prompt (the shipped qa-research one does, which discards every plugin prompt section), the note travels as injected context on the conversation instead of as prompt text. It is written once per profile, and subagents of the chat get their own copy. Values are self-declared and the note says so: the agent names the identifier it searched by and asks when the results contradict the request. The feature needs no switch beyond accounts, though accounts.profile.enabled and inject exist for deployments that want the form without the note, or neither:

accounts:
  enabled: true
  profile:
    identities:
      - key: jira
        label: Jira
      - key: gitlab
        label: GitLab
    instructionsMaxLength: 2000

qa-accounts profile <email> fills the same fields from an operator shell (--identity jira=i.ivanov, --instructions-file, --clear-identity), which is how a fresh deployment gets everyone's handles in place before users log in. See Configuration for the limits and the prompt's exact wording.

Starter messages

The same Настройки dialog carries a «Быстрые сообщения» section where the signed-in user defines their own starter buttons: each entry is a label (what the button reads) and a prompt (what pressing it sends), so a button can say «Мои задачи» while sending a full tracker request. The deployment's suggestedQuestions stay visible next to the user's own buttons unless the user hides them with the section's toggle. The list is stored on the account (next to the profile, in the accounts file), replaces wholesale on save, and is pure UI preference — none of it reaches the agent prompt. accounts.starters.enabled (default true) turns the section off for deployments that want the buttons to stay operator-defined.

Session policies:

  • browser-persistent restores the id stored in this browser, replacing stale ids with a new session;
  • new-on-load creates a session each time the QA surface mounts;
  • fixed requires fixedSessionId, never creates a replacement, and is meant only for controlled single-user deployments.

New chat is disabled by default. To expose it, set lockdown.allowSessionReset: true plus either ui.showReset: true (header button) or ui.showSessionList: true (sidebar button). Pressing it opens a draft composer and creates nothing: the DSH Session is materialized lazily by the first prompt, so the chat list stays quiet until a message is actually sent, and the old session stays intact for operator inspection. The sidebar orders chats by the host's last update, so merely opening a chat never moves it.

Regeneration: the last committed answer offers a retry action. The session log is append-only, so "regenerate" sends a hidden instruction as an ordinary prompt and the answer arrives as a follow-up turn; the projection hides that instruction and the consecutive turns read as variants of one question, navigable with a < 2/2 > switcher (newest shown by default).

Sources are structured Host-owned provenance, independent of ui.showToolActivity. Successful reads/fetches, bounded web-search evidence, Jira/Confluence/knowledge results, and inherited subagent sources are normalized and deduplicated into one turn bundle. That exact bundle feeds the answer footer and the right rail's sources tab and is persisted in the plugin-owned $DSH_HOME/qa-sources.json, so reload does not rerun tools and no custom event enters the Harness session journal. Search-only discovery stays hidden by default.

Legacy sessions written by earlier releases can be repaired while DSH is stopped. Preview changes first, then apply them with an automatic backup:

qa-repair-sessions
qa-repair-sessions --write

The repair only marks legacy safety-gate/* and qa/sources records as ignorable; it does not delete them. Each changed session file is backed up as *.pre-plugin-event-repair.bak before atomic replacement.

Local file cards open a source-scoped, read-only preview after Host-side real path validation against the roots the QA read policy opens: the chat's own directory, the configured shared read-only directories, and the attachment store. Markdown opens rendered by default with an HTML-free renderer and offers Rendered / Raw; raw mode jumps to recorded line ranges. The endpoint cannot browse or write files and refuses paths that are not evidence in the canonical bundle.

Observable local subagents are inherited recursively. The internal qa_report_sources tool covers opaque delegated providers and is admitted as a provenance-only capability even when it is not listed among ordinary QA tools. A provider that neither exposes events nor reports sources marks the turn provenance incomplete. A note in the conversation tells the model not to append a manual Sources/Источники bibliography.

Images: the composer accepts PNG/JPEG/WebP/GIF via drag & drop onto the composer, paste, and the picker button, several at once (soft client caps: 8 images, 15 MB each). Images ride the prompt as base64 uploads the Host promotes to durable attachments, so they survive reloads; sent images render as clickable thumbnails on the message. Whether the model can see them depends on the deployment's model (vision).

Attachments: the same picker, drop zone and paste path also take text files (md, txt, log, and the other extensions in attachments.extensions), and pasted text longer than attachments.pastedTextLines (default 200) becomes an attachment named after its line count instead of filling the input field. A file is staged on the Host through the browser upload service and the prompt cites the returned receipt, so the durable copy survives reloads; the transcript shows it as an extension badge, its name and its size. Unlike an image, a file reaches the model as the path of that stored copy rather than as content, so lockdown.toolPolicy.allow has to keep read for an attachment to be usable. attachments.textFiles: false restricts the composer to images again, while maxFileBytes and maxPending cap one file and the combined number of images plus files per message.

The right rail is the chat's side panel, mirroring the Harness right Sidebar's pattern: a tab strip is the panel's whole top edge, and the strip's close control collapses the column. The sources tab carries the grouped list and preview the sources drawer used to render — a message footnote opens it pinned to that answer's subset, and «Все источники» returns to the whole chat. The «Файлы» tab (header button with a live count) lists every attachment this chat sent, grouped per message newest first, with the same file handles the transcript shows and image thumbnails resolved from the session's asset repository; each group jumps back to its message. Below 600px the rail goes full-bleed. The agents drawer keeps its own header drawer for now.

Slash commands and skills

Off by default. Typing / in the composer normally gets the same refusal it always did («Команды со слешем недоступны в режиме помощника»), and nothing about that changes for a deployment that upgrades.

Turn it on with the master switch, then say exactly what it admits:

lockdown:
  allowSlashCommands: true

slashCommands:
  skills:
    mode: allow-list      # deny-all | allow-list | all
    allow:
      - generate-tkp
      - generate-tz
      - gap-analysis
  commands:
    mode: deny-all        # deny-all | allow-list | all
    allow: []
  palette:
    enabled: true
    fuzzySearch: true
    maxVisible: 12
    showDescriptions: true
    showKindBadge: true

The switch and the policy are two separate decisions: turning slashes on opens the palette, and the palette offers only what the two lists name. A deployment that enables the switch and declares nothing gets the legacy behaviour — every user-invocable skill of the chat, no commands at all — and the Host says so once in its log (slash.legacy-defaults) rather than silently behaving as if it had been configured.

The two kinds behave differently, and the difference is the point:

Skill                                    Human command
  /generate-tkp Сделай ТКП                 /compact
  → ordinary model turn                    → the Host runs it
  → the native skill consumer injects      → the model never sees it
    the skill's instructions               → command/run + command/done land
  → QA reads no SKILL.md and injects       in the session log, projected as a
    nothing itself                           control row, not an answer bubble

A skill with user-invocable: true and disable-model-invocation: true shows up in the palette and runs; the model still cannot see it. A skill with user-invocable: false never appears. /name typed inside an ordinary sentence still works the way it does everywhere else in the Harness — QA does not rewrite that path — but when the deployment withholds that particular skill, the composer says so before the turn runs instead of letting the user believe it took effect.

Keyboard and touch: / opens the palette above the composer and it closes as soon as a space is typed (you are writing arguments by then). ↑/↓ move, Tab and Enter insert the invocation without running it — the second Enter sends — and Escape closes. Clicking or tapping a row inserts it and leaves the caret in the field. A skill and a command that share a name are two separate rows, and a hand-typed /plan when both exist asks which one you meant rather than guessing.

Admission is the Host's, not the browser's. The catalog arrives already filtered by the policy and by the chat's role, and /compact typed by hand is re-checked against the same policy before the native runtime is allowed near it. The slash interface changes nothing about tools, the sandbox, the permission preset or approvals: a skill invoked by hand carries exactly the permissions it carries when the model loads it.

The settings card carries the same policy under «Слеш-действия», with the allow lists as plain name lists — the config stores names, never ids.

Panel extensions

QA Surface can host optional feature panels without importing those features. The shell owns the launcher, side-by-side/fullscreen layout, resizing and generic close chrome; an extension owns its feature state and controls. No panel is shown, and no launcher space is reserved, when no extension is installed.

An external client plugin uses two registrations. Metadata and navigation go through the qaSurfacePanels service; the React body is registered separately in the keyed qa.surface.panel slot under the same implementation id:

import type { Context } from "@deepseek-ai/cordis"
import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"
import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"
import { QA_SURFACE_PANEL_SLOT } from "@yadsh/dsh-qa-surface/client/panels"

const id = "@example/dsh-qa-browser"

function BrowserPanel(props: PropsRuntime<typeof QA_SURFACE_PANEL_SLOT>) {
  // panelId, panelKind, sessionId, visible, presentation, params,
  // actions and a registration-lifetime AbortSignal arrive in props.
  return null
}

export const inject = ["slots", "qaSurfacePanels"]

export function apply(ctx: Context) {
  ctx.effect(() => ctx.qaSurfacePanels.register({
    id,
    kind: "browser",
    title: () => "Browser",
    icon: "browser",
    order: 100,
    keepMounted: true,
  }))

  ctx.slots.inject(QA_SURFACE_PANEL_SLOT, () =>
    ctx.slots.register(
      { name: QA_SURFACE_PANEL_SLOT, key: id },
      BrowserPanel,
    ),
  )

  ctx.qaSurfacePanels.open("browser", {
    reason: "extension",
    focus: false,
  })
}

The extension should declare both slots and qaSurfacePanels in its client inject list instead of polling for load order. keepMounted: true preserves local/continuous UI state while another panel is active; hidden retained bodies are removed from keyboard navigation. Presentation state is browser-local and never enters the DSH Session log. Browser processes, tools, security policy and artifacts remain the responsibility of the Browser plugin, not QA Surface.

User Settings extensions

An optional client plugin can add a first-class page to the signed-in user's existing Настройки dialog through the published @yadsh/dsh-qa-surface/client/settings contract. Register the page with the qaUserSettingsSections service; QA Surface owns the navigation and supplies only the current account token to the selected page. The extension must still authorize every Host call server-side and must not persist credentials in the browser.

import type { Context } from "@deepseek-ai/cordis"

export const inject = ["qaUserSettingsSections"]

export function apply(ctx: Context) {
  ctx.effect(() => ctx.qaUserSettingsSections.register({
    id: "integrations",
    title: "Интеграции",
    order: 40,
    component: IntegrationsPage,
  }))
}

The signed-in account outside the dialog

A card mounted in the host's own settings (settings.plugin.item) has no panel props to read the account from, so the same contract also publishes the session as the qaUserSession client service: checking, anonymous or authed with the bearer credential the principal-scoped QA remotes authorize with. It follows the same account controller the pages use, so a card and the dialog never disagree. Subscribe to it with useSyncExternalStore and render account-bound controls only for authed — without an account every call would be refused, and the credential is transport authentication only: never persist it, log it, or put it in a URL, a tool argument or any model-visible value.

import type { Context } from "@deepseek-ai/cordis"

export const inject = ["qaUserSession", "slots"]

export function apply(ctx: Context) {
  ctx.effect(() =>
    ctx.slots.inject("settings.plugin.item", () =>
      ctx.slots.register(
        { name: "settings.plugin.item", key: "my-namespace" },
        MyCard,
      )))
}

Subagents: the deployment may opt the delegation family (subagent, subagent_fork, send_message, list_agents, interrupt_agent) into the lockdown allow-list; the preset must mount them. Launches then render as first-class work items (description, background flag, durable child id), settlement notices appear as status rows, an "Агенты" header drawer lists the chat's subagents with live status, and any subagent opens as a read-only live transcript (composer disabled, one click back to the chat) - viewing never attests or writes.

ui.showSessionList: true renders a minimal chat-history sidebar beside the conversation. By default it lists only the current user's chats: the client keeps a per-browser id index under <storageKey>:v1:<route>:chats in localStorage (capped at 50, most recently used first) and intersects it with the Host session list, so users sharing the deployment never see each other's chats. An admin can explicitly enable accounts.showOtherUsersChats: true to add chats owned by other QA accounts, grouped by owner. Switching re-runs the full policy attestation, and that attestation materializes the chat's agent when the Host does not hold one: DSH builds an agent on demand, so a chat restored after a Host restart still opens (its composition is resumed from what the session recorded) instead of failing as unavailable. If that recorded agent preset, workspace or model belongs to an older deployment configuration, the history still opens in an explicit compatibility read-only mode. Send, stop, approvals and questions stay disabled so the historical session cannot bypass the current workspace or permission boundary; use New chat to continue under the current configuration. A chat the Host no longer lists is pruned from the index, and so is a delegated subagent session: the rows, the chat counter in the account settings and the first-login claim batch all read the same lineage marks the host list carries, so a subagent's transcript can never be reopened, counted or migrated as a chat. Each row carries a two-click delete control that removes the chat from this browser's index; deleting the chat that is currently open continues in a fresh attested session. Host-side sessions are not deleted — DSH 0.1.x exposes no session-deletion seam. The sidebar hides below 600px viewports.

The sidebar footer shows the deployed plugin version. Clicking it opens a changelog dialog with a curated per-version summary (features and fixes); Escape or a backdrop click closes it.

On /qa, the plugin shadows DSH's stock welcome-notice onboarding entry and renders a route-owned Russian testing disclosure. Keeping the visible dialog in the QA overlay prevents DSH's blank-session onboarding lifecycle from dismissing it when a question is submitted. It explains the DeepSeek Harness preview foundation, QA review of questions and answers, the work-related scope, and local in-contour model processing. Explicit acknowledgement is stored as a versioned browser-local flag; changing the disclosure version shows it again. Other DSH routes retain the stock onboarding entry.

The default locked mode requires a deployment permission preset named qa-read-only. Per-user writable mode uses a separate preset such as qa-workspace-write. interaction.approvals: interactive does not change this requirement: it parks an ask returned by a composed tool gate in the QA view, while the permission preset must keep approval: never as the independent fail-closed backstop. Setting the preset itself to approval: ask fails deployment preflight with (reason: permission-preset) before a Host session is created. Extend the existing @deepseek-ai/dsh-permission-presets row without changing its process-wide default:

- id: permission
  name: "@deepseek-ai/dsh-permission-presets"
  config:
    presets:
      read-only:
        sandbox: read-only
        approval: ask
      workspace-write:
        sandbox: workspace-write
        approval: ask
      danger-full-access:
        sandbox: danger-full-access
        approval: never
      qa-read-only:
        sandbox: read-only
        approval: never
        name: QA Read Only
        description: No filesystem mutations and no permission escalation.
      qa-workspace-write:
        sandbox: workspace-write
        approval: never
        name: QA User Workspace
        description: Writes only inside the attested per-user workspace.

The shipped tool allow-list is empty. Add only reviewed tool names from the actual deployment. A name that is not registered fails closed. The Host restriction retains exact allow-listed tools from the agent preset's ancestor scope, and the additional execution guard also denies session-scoped tools and run_code unless their exact names are allowed. Host-installed integration plugins may add their own narrowly scoped tool names through the QA Surface service; those executors must independently resolve the owner-attested root principal and fail closed for unowned or child sessions.

Subroles and capability policies

With accounts.enabled: true, every QA session has exactly one active agent subrole. The Host resolves its effective capabilities as the union of the minimal system-required set, Common capabilities, and that one subrole; it never unions all roles assigned to the user. The chosen role and effective snapshot are stored with session ownership. Existing conversations never gain new capabilities after an administrator edits a role, while a capability that disappears from the running registry is revoked immediately.

Administrators manage subroles, Common Tools/Skills, user assignments and the audit trail at /qa/admin. Capability choices come from the live tool and skill registries; configured-but-missing entries remain visible and are not deleted. Admin authorization affects only the management API and never grants agent capabilities. Preview as role creates an ordinary session using the selected role's real server-enforced policy and shows the preview banner, which carries the way out. The preview belongs to the navigation that opened it: it ends when that entry is left, and an ordinary account never holds it, so a chat started later in the same tab runs under the account's own default profile. A user page reports each assigned profile's effective capabilities the way a session resolves them: the deployment's pinned tools plus Common and the role, the skill-grantable ceiling separately, and declared audiences among the skills.

The configuration lives in $DSH_HOME/qa-capability-policies.db (a pre-0.8.0 qa-capability-policies.json beside it is imported on first use); user assignments and session snapshots stay with the existing account store. Tools are filtered with an agent-scoped restriction and a pre-execution gate. Skills use an agent-scoped skill consumer that publishes only the allow-listed catalog and rejects direct out-of-policy loads with SKILL_NOT_AVAILABLE. The browser selector is hidden for a single assigned role. Switching roles after a meaningful turn requires confirmation and creates a new conversation.

Tools that arrive with a skill, and tools taken away

A role's tools are split into three classes. always tools are visible from the first model step. skillGrantable tools are a ceiling, not a grant: they stay out of the model's tool list until an activated skill requires them, which keeps a large catalog such as browser automation out of every step of every conversation. An older flat tools: [] list is read as always, so an upgraded deployment never hands out more than it did before.

deny withdraws a tool. The deployment's pinned toolPolicy.allow reaches every profile, so a pinned name cannot simply be unchecked in a role; a denial beats it, beats the Common layer and beats every skill, and it narrows the ceiling below. Because the ceiling is a property of the conversation rather than of one agent, the agents a chat delegates to — subagents and the named domain experts — are held to it as well: they may reach anything the role can reach, including tools a skill would grant, and nothing beyond it. Withdrawing dsh_git_* from a role therefore withdraws it from that role's experts too.

Skills declare their own audience

A skill describes itself inside the ordinary metadata block of its SKILL.md, so the file stays a valid Agent Skill outside a QA deployment and no upstream schema is forked:

---
name: browser-research
description: Research websites through browser automation

metadata:
  qa-surface:
    version: 1
    audience:
      type: subroles        # or: type: common
      include: [analyst, presales]
    tools:
      requires: [browser_open, browser_click]
      grant:
        lifecycle: session  # the only lifecycle in v1
        requireAll: true
---

The audience decides visibility; the role ceiling decides what a skill may ever receive. A load intersects the two, so a skill cannot widen its own access. A skill that declares nothing stays Unassigned until an administrator assigns it, which is safer than treating every new skill as Common.

Loading the instructions and widening the toolset is one operation. A strict skill (requireAll: true) refuses to activate when one of its tools is unavailable, and a best-effort skill activates with a visible warning that names what it did not receive. The model's skill call and a typed /skill-name activate the same grant; a /name naming a skill outside the subrole is withdrawn before the step is assembled. Grants last for the current agent, and several loaded skills union their tools — activating a second skill never revokes what the first one still holds.

Administrators edit assignments as an overlay, never by rewriting SKILL.md: a role can be added to or withdrawn from a declared audience, a skill can be forced on for every role or disabled outright, and the Skills page shows the declared audience next to the effective one with a Healthy/Degraded/Blocked state. Every activation is recorded on the session record with the requested, granted and denied tools, so a later review can see what a conversation actually gained.

Administrative console

/qa/admin is the review and administration surface. It is part of the QA page itself, not a separate application, and it is open to admin and reviewer accounts. Reviewer sees conversations, the review queue, feedback and analytics; only an administrator sees users, capability policies and audit.

The console covers the quality loop end to end:

  • Overview — conversation, rating and review counters, the items that need attention, and the newest signals.
  • Users — authorization role, enabled/disabled status, assigned QA subroles and the default one, plus per-account activity. Disabling an account is the operation to reach for: historical conversations, feedback and reviews stay attributed to their author, and the last enabled administrator cannot be demoted, disabled or removed.
  • Conversations — every conversation the deployment knows, filterable by user, subrole, date range, rating and review state, with a viewer that reads the stored transcript: messages in recorded order, tool calls with their arguments, results and errors, per-message feedback, and the capability snapshot frozen when the session started. A link can point at one message (/qa/admin/conversations/<id>/<seq>). An administrator can also delete the conversation from its own page: that removes the chat itself — its stored log, the sessions delegated from it, its ownership record, its ratings, reviews, queue entries and collected sources — unlike the sidebar's "delete chat", which hides a row in one browser. The Host refuses while the Harness still holds the session open or when the deployment stores sessions somewhere directories cannot express, and audits the act.
  • Review queue — what needs attention, derived from unanswered negative feedback, explicitly queued conversations and failed tool calls. A reviewer classifies issues across answer, context, tools, skills and access, sets a severity, writes notes and names the remediation target.
  • Feedback — every rating against the exact answer it judged, with the optional reason and comment the user gave.
  • Analytics — rating coverage, positive share overall and per subrole, issue distribution and a daily trend. These are user-satisfaction signals; the console never presents them as accuracy.
  • Audit — one timeline of authorization changes, account status, subrole assignments, policy edits and review verdicts, each with its before/after image.

Authorization is a permission table, not an isAdmin flag, and every administrative entry point names the permission it needs; the Host re-checks it on the call it serves, so a hidden control is convenience rather than the boundary. Review material is the most sensitive data the package handles, so tool arguments and results are bounded previews with credential shapes masked, behind a redactor a deployment can replace.

Security and deployment

/qa is a presentation boundary, not an authentication boundary. Protect it with the same deployment authentication and DSH browser trust policy as the operator UI. The plugin never auto-approves a permission request or answers an interactive agent question. Configure a least-privilege, non-interactive agent preset for end-user deployments. On a shared Host, a user who can open /, call the normal DSH APIs, or use another authenticated client can bypass the QA presentation. Use a dedicated DSH process/identity and network boundary when /qa must be a strong security boundary.

The embedding.frameAncestors field is reserved deployment metadata in 0.1.x; the plugin does not mutate CSP headers. Configure frame-ancestors at the trusted reverse proxy if iframe embedding is required.

Serving the QA rig over the LAN

The plugin itself needs no changes to be reachable from other machines; the web server's bind address is a deployment composition decision. DSH refuses dsh --host 0.0.0.0 on the command line on purpose, so network exposure is expressed with the shipped patch overlay instead:

pnpm dsh --profile qa-surface --no-open --port 3082 \
  --patch plugins/dsh-qa-surface/deploy/qa-lan.patch.yml

The overlay sets the webserver row to 0.0.0.0 while keeping the --port flag working. On an all-interfaces bind DSH derives trusted /api authorities from the machine's LAN IPv4 addresses, so http://<lan-ip>:3082/qa works without extra configuration; users reaching a DNS name instead of an IP literal need --trusted-host <name> on the same command line.

LAN browsers cannot read the DSH settings namespace (settings RPCs are pinned to loopback by the gateway). The plugin covers this itself: a browser that sees the namespace as unavailable reads the effective configuration through the qaSurface/describe Host Remote, so branding, session pinning and lockdown UI switches keep working over the LAN. Host-side enforcement was never dependent on that read path.

describe takes no token on purpose — the browser needs the configuration before it can render anything — so treat its answer as public deployment metadata: any /qa visitor can read the full resolved configuration, not only the audience-facing projection, including operator-side values such as the lockdown.sharedReadOnlyRoots paths. Keep credentials and secrets out of the qa-surface configuration entirely; the plugin declares no field for them, and nothing in the channel redacts the resolved values.

What a LAN deployment does not change:

  • DSH has no authentication or TLS on this port; anyone who can reach it can drive the harness, and the full operator UI at / stays reachable next to /qa. Scope the port's reachability (subnet-limited firewall rule, VPN or tailnet, authenticating reverse proxy) and use a dedicated process identity.
  • Loopback-only settings, directory picking and credential RPCs stay refused for LAN clients. On /qa, the plugin replaces DSH's non-persistent welcome step with a QA-specific testing disclosure and remembers its exact copy version in that browser's local storage. The stock DSH notice remains unchanged on operator routes.

See Configuration for the config-channel details.

See Architecture, Configuration, and Compatibility.

Develop

pnpm --filter @yadsh/dsh-qa-surface check

The package publishes a classic DSH browser module whose loader id is the full package name, @yadsh/dsh-qa-surface.