DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录
← Plugins

@tsxapp/dsh-plugin

Plugin

DeepSeek Harness 插件:注入 tsxapp 技能,并使用 tsxapp 在侧边栏中打开 *.app.tsx 文件

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add @tsxapp/dsh-plugin@0.2.0
README兼容性版本

兼容性与来源证明

Plugin 以 @tsxapp/dsh-plugin 发布,当前版本为 0.2.0。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
web
发布来源
npm
Registry 更新时间
2026/11/5

版本

0.2.0stable
2026/10/26
0.1.0stable
2026/10/23

相关插件

正在加载相关插件…

最新版
0.2.0
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
249.1 kB
文件数
9
Surface
web
许可证
MIT
发布源
npm
周下载
54
查看源码 ↗项目主页 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题

相关插件

继续浏览 developer-tools 分类下经过校验的插件。

Web App@deepseek-ai/dsh-web-appdsh 浏览器界面捆绑包:位于 dsh-base 之上的 Web 补丁层,加上运行时粘合插件(提供前端 dist、Web 界面提示符、bash 运行时变量和 URL 行)Sdk Minimal@deepseek-ai/dsh-sdk-minimal独立的最小 SDK 配置包:JSON-RPC、一个 DeepSeek 适配器、持久化 Shell 和 JSONL 会话Sdk App@deepseek-ai/dsh-sdk-appdsh SDK 配置包:基于 dsh-base 提供 stdio JSON-RPC 服务和进程生命周期管理Experimental Inspector@deepseek-ai/dsh-experimental-inspector用于 Host 调试和 Client Runtime 检查的实验性跨 realm CDP hub

README

@tsxapp/dsh-plugin

简体中文 | English

A DeepSeek Harness (DSH) plugin that wires tsxapp into three places in DSH: a skill, a sidebar tab, and a tool.

  • Skill injection: the host half registers a tsxapp skill, so the agent knows the .app.tsx contract: default-export a component that takes no props, import capabilities from @tsxapp/runtime, the permission model, and Tailwind. Installing the plugin gives you the skill; uninstalling it removes it — nothing is written into ~/.dsh/skills/.
  • Sidebar: the browser half registers a tab type with patterns: ['*.app.tsx']. Opening any *.app.tsx from the file tree does not show plain text — it shows a tab for that app: the page is drawn right inside the tab (embedded iframe, no system browser), with a launch animation, run status, live logs and source with line numbers. The top-right corner has two tools: "Restart & reload" (stop the service, start it again and swap the whole iframe — for when you don't want to wait for HMR after an edit, or the page state has gone haywire) and "Open in browser". Close the tab and the service stops with it. When it fails to start, the Fix it for me button opens a new session in the current workspace so the agent fixes it with the whole project as context (not just the foo.app.tsx subdirectory); a runtime error reports itself as a non-blocking banner (the host's own Toast) — expand it only if you want the stack trace.
  • Copy follows the host language setting: the dictionary is registered on DSH's locale service (namespace tsxapp, one Chinese and one English table), so the UI follows the host's current language and changes live when you switch — it does not look at the browser language.
  • Tool: tsxapp_preview — the agent calls it once after editing: type check → serve → screenshot. The screenshot comes back as an image inside the tool result (the model sees the page itself) and the report carries the service URL and dimensions; it also opens the tab in the sidebar, so the human and the model look at the same page. If the type check fails it just returns the report (no service, no screenshot). (The separate tsxapp_check from the previous version has been folded in here: for an agent the action is always "edit → take a look".)

Source and build

The source lives in src/: the host half is a handful of .ts files, the browser half is tsx components under src/client/. Each half has its own tsconfig, because they live in two different worlds (Node vs DOM + JSX):

EntryOutputRuns intsconfig
src/index.tsdist/index.jsNode (DSH host process)tsconfig.host.json
src/client/index.tsxdist/client.jsBrowser (DSH client module table)tsconfig.client.json

tsconfig.json is now just the base both share (files: [], it compiles nothing itself).

pnpm dsh:build       # tsdown -c packages/dsh/tsdown.config.ts (two configs, host then client)
pnpm dsh:typecheck   # tsc -p tsconfig.host.json && tsc -p tsconfig.client.json
pnpm dsh:test        # run the unit tests (vitest, builds first)

Four things worth knowing:

  • The skill body is imported statically. src/skill.ts does import '../skill/SKILL.md' directly, and the markdownText plugin in tsdown.config.ts turns it into a string constant at build time — nothing is read from disk at runtime, so there is one less "file isn't where we thought" failure mode. The skill/ directory still ships with the package, because resourceBase points at it: when the model reads references/*.md on demand it goes through real files.
  • client.js has to be a classic script. The source is plain ESM (import { useState } from 'react'); the build uses the cjs format so rolldown turns the imports into require(...), and the loaderEntry plugin then wraps it in window.__ModuleLoader__.load({ id, factory }). React stays external via deps.neverBundle — it only exists in the host's module table, and the @types/react in the package is only for types. JSX uses the production runtime (react/jsx-runtime): react/jsx-dev-runtime is not in the module table, and switching to it means a blank page — the require stub in the self-tests blocks that path.
  • The browser half's types also come from the official packages. The slot name, useTabInfo, and the shape of the tab definition are all merged from @deepseek-ai/dsh-client-ui-{sidebar-right,renderer,slots}: PropsRuntime<'sidebar.right.pane.tab'> is exactly the props of the pane body, and priority can only be 'extension' | 'builtin' | 'fallback' — hand-copying these would have gotten those string literals wrong long ago.
  • Same for the host half. There is not a single hand-written service declaration in src/types.ts: import type on the official packages triggers cordis's declaration merging (declare module '@deepseek-ai/cordis'), and the few methods the plugin uses are picked off Context. If the official packages change a signature, dsh:typecheck fails immediately. Those packages are in devDependencies and are all import type — not a single line of @deepseek-ai shows up in the host output. The browser half has one exception: @deepseek-ai/dsh-client-ui-primitives really is required — the Toast / Modal / Button used by the error banner must be the host's (they live in DSH's frozen module table, see in ). Bundling our own copy would mean standing up a second design language next to DSH's, plus carrying another and another focus manager.

Install

# Build first (DSH loads dist/)
pnpm dsh:build

# Inside DSH (only the web profile gives you a browser UI)
dsh plugin --profile web add /abs/path/to/tsxapp/packages/dsh
# Restart dsh web and refresh the page

Confirm it is mounted:

dsh --profile web --dump-config | grep -A2 tsxapp-dsh

While developing, run pnpm dsh:build after each change and restart DSH — DSH locks the plugin set at startup, so hot-editing the plugin itself has no effect.

How the service starts: call the core, no subprocess

The plugin does not spawn the tsxapp command; it calls the core inside the DSH host process — the top of src/runs.ts is a single static import:

import { chooseLocale, startApp } from '@tsxapp/core'

const handle = await startApp({ file, cwd, version, options, logger, banner: false })
handle.url     // host page URL carrying the one-time token
await handle.close()

Splitting the CLI from the core was done exactly for this: the CLI shell (argument parsing, printing the banner, installing SIGINT hooks) is all dead weight for an embedder — the banner would scribble over the host's terminal, and process.exit(0) would take DSH down with it. The plugin only gets the AppHandle the core hands back: no pid, no exit code.

startApp lives in packages/core/src/run.ts; the CLI's runApp is just that plus the signal hooks.

Who cleans up: one effect per app

Starting is asynchronous (there is a compile in the middle), and "stop" may arrive first at any moment. There is no hand-written state machine for this — each file corresponds to one cordis effect:

run.release = ctx.effect(() => () => {
  ac.abort('stopped')   // make an in-flight start give up immediately
  return slot.close()   // close the handle; a handle that arrives after teardown is closed on entry
}, `tsxapp-dsh: ${basename(absolute)}`)

So "the user clicks stop" (calling release() by hand) and "the plugin is unloaded" (the fiber calls it in reverse order) are the same code. That is why the plugin has no loop over "services still running at unload time", and no generation counter to decide whether a just-returned handle is already stale — the handle slot's retired flag says it in one place (src/runs.ts).

In ctx.getEffects(), every running app is an effect tagged with its file name.

Where the core lives

The core is a normal dependency of this package — package.json says "@tsxapp/core": "workspace:*" and pnpm links it to packages/core. The code is a single static import … from '@tsxapp/core': developing in the repo, installing into a DSH profile, and npm install on someone else's machine all go through the same Node resolution rules. There is no config.core, no candidate path list, and no "if not found, try the next one" fallback — so there is also no "core is too old / guessed the wrong location" branch that only blows up at runtime.

The price is a different failure shape: when the core cannot be installed, dist/index.js itself fails to import and the whole plugin fails to activate (DSH reports 1 entry did not activate), instead of coming up halfway and then answering "core not found". That is much easier to diagnose than a silent degradation — one look tells you a dependency is missing.

Remember to run pnpm build first: the core's entry point is dist/index.js, which is also where the type declarations come from.

Configuration

The one line in cordis.patch.yml is the configuration, and config uses line replacement semantics (not a deep merge). The keys map one-to-one onto tsxapp's CLI flags:

- insert:
    - id: tsxapp-dsh
      name: '@tsxapp/dsh-plugin'
      config:
        port: 5173            # starting port; the core increments if it is taken
        allow: []             # rules allowed from the start, same syntax as --allow
        deny: []
        startTimeoutMs: 20000 # upper bound for waiting on the core

Available keys: port host hmr accessControl typecheck persistPermissions allow deny title verbose locale startTimeoutMs.

There is no core (the core is a static dependency, its location is not configurable) and no open (opening a browser is the top bar's link; the core does not do it itself).

typecheck (default true) is the type/syntax check before startup: if there are errors the start fails and the sidebar logs show that report (same wording as the CLI), and you click again once fixed; pass typecheck: false to bypass it (equivalent to the CLI's --no-typecheck). allow is for capabilities that should "just work when opened", for example:

config:
  allow: ['fs.list:.', 'fs.read:./data/**']

Capabilities not listed in allow are still authorized by a runtime prompt (in the host page's Monitor panel you can click "Always allow" / "Always deny" to turn this one choice into a rule).

What is in the sidebar

Opening any .app.tsx starts it automatically and then draws the page right inside the tab — no system browser anymore.

While starting, the whole tab is one loading animation: a ring with a fading tail is spinning, with no copy, no buttons, no top bar and no panel — there is nothing worth the space during those few seconds of compiling. Once it is up you get the shell below:

AreaContent
Top barA file path on the left (…/sales/demo.app.tsx, truncated when too long, hover for the full path) and three icons on the right — "Restart & reload" / "Screenshot" / "Open in browser" (space-between)
StageThe app page (an <iframe> pointing at the host page); when it fails to start (timeout / failure) this is the title + the full error reason + "Fix it for me" / "Retry"
Bottom panelTwo pills, "Source" (the host's CodeBlock: syntax highlighting + line numbers + copy, first 512 KB) and "Run log" (the core's logger, a 400-line ring buffer showing 200 lines), plus one expand / collapse toggle, collapsed by default

There is no status pill and no start/stop button group: starting happens when the tab opens, stopping happens when it closes. On the normal startup path there is not a single button — only the failure screen has "Fix it for me" and "Retry" (the former solid, the latter outlined).

The UI has exactly three states (the Phase in use-run.ts): starting (the whole tab is just a spinner ring — opening a tab, clicking "Restart & reload", and auto-restarting after being stopped all land here), has a page, and cannot start. When the service is stopped (another tab with the same address was closed, the host reloaded the plugin) it does not show "service stopped, click below to restart" — the tab brings it back up by itself.

The failure screen

One title line ("Cannot start" / "The core never came up"), and below it the error reason itself: the core compresses the full report (that wall of type-check output, or a single file not found) into one log line, and this screen takes the last one and lays it out verbatim — left aligned, monospace, scrolling when too long — instead of sending the user down to the log panel to dig.

Below the reason are two buttons: Fix it for me (the host's primary fill + four-point star icon) packages the path, status, failure reason and the log tail into one message and opens a new session in the current workspace for the agent; Retry (outlined + back arrow) sends the start request again.

Two "Screenshot" buttons: measure the chain segment by segment

"Screenshot" involves three hops, and any broken hop turns into the tool's no browser answered within 20s. So there is a "Screenshot" button in the top bar of the pane body, and another at the right of the host page's Monitor panel header:

ButtonWhereWhich hop it measures
The sidebar onePane body top bar, right of "Restart & reload"Sidebar → host page → app page
The host page oneHost page top bar, right of "Reload" (left of the Monitor one)Host page → app page (not through the parent window)

Neither goes through the tool's mailbox (the tool sits on the host side and can only receive via a 2.5-second poll). Therefore: sidebar works but the tool doesn't ⇒ the problem is the mailbox / polling / "someone has this tab open"; neither works ⇒ the problem is "host page ⟷ app page" (look for the red screenshot line in the host page's Monitor log tab).

On success it downloads a PNG; on failure the sidebar shows a banner and the host page writes an error into the log tab and brings that tab to the front.

Both buttons live in the top bar; do not put them in the default-collapsed Monitor panel — a button you have to expand a panel to see is a button that does not exist (the first version made exactly this mistake: it sat on the right of the panel header and nobody found it).

To look at the app outside the sidebar, use "Open in browser" at the top right — it is a real link (<a target="_blank">) handed to the host's "new window" handling. On desktop the page lives on file://, where window.open is often blocked into null (the click does nothing); the link is the only path to "open in the system browser". While the app is not up yet it has no href and cannot be clicked.

Autofix: hand it to the agent when it cannot start

The failure screen has an Autofix button: clicking it creates a new session in the current workspace (falling back to the app's directory when no workspace is available), packages "file, status, failure reason, log tail" into one message for the agent, and switches the main view to that session — so you can watch both the fix and the result.

  • It goes through DSH's public client services, never touching host internals: ctx.sessions.create() → inside sessions.using(id, { source: 'tsxappAutofix' }, …) session.prompt(content, 'queue') → ctx.uiWorkspace.openSession(id) (see src/client/autofix.ts).
  • A new session, not a message shoved into the current one: debugging this round has its own context, and it is not affected by whether the current session is busy.
  • When the host has no sessions / uiWorkspace (older versions, trimmed distributions) the button does not appear, rather than erroring when clicked.

Runtime errors: a banner, not a roadblock

Once the app is running and crashes (uncaught exception, unhandled promise, React render error), the host page relays it up and the sidebar reports a DSH Toast (@deepseek-ai/dsh-client-ui-primitives, createPortal into document.body): one line at the top "App error: …" followed by two inline actions, "or fix it for me" and "· Error details", which fades out on its own after a few seconds. It does not block the app — an error report is a notification, not an interception.

Clicking "fix it for me" takes the same autofix.ts path, except the message says kind: 'runtime' — "the app runs but errors" and "it cannot start" are two different things, and the agent needs to look at different information. Clicking "Error details" (or when the fix did not come up) expands a Modal: error summary + stack trace, the card allowing full width, the content area scrolling on its own, and two buttons at the bottom, "Close" / "Fix it for me" — you fix it right there after reading the stack, without closing the details and waiting for the banner to slide back in. That step is user-initiated, so a modal is appropriate.

Where the boundaries are:

  • Only the "crashed" kinds count (uncaught / rejection / render). A console.error line from the app itself, or a 404 on an image, is not reported: during development Vite also writes errors to the console (that --no-hmr [vite] failed to connect to websocket is one), and asking "should I fix this?" about them is just noise;
  • Every one is reported, even if the same line shows up again — noise reduction happens on the host side (#pushLog merges repeats with the same signature within 3 seconds and does not relay the merged ones), so reporting the same line every frame still lands as one notification;
  • The "fix it for me" action only appears when someone can fix it (sessions / uiWorkspace both present); otherwise only "Error details" is left;
  • When the banner fades out, nothing happens — the user is not interrupted while looking at the app.

The tool: tsxapp_preview

The hook registers a tool the agent can call; it does three things in one go, strictly ordered from cheap to expensive:

tsxapp_preview({ file: 'examples/demo.app.tsx', limit: 30 })
  1. Type check — calls the core's typecheckApp() directly, the same yardstick as the pre-startup check (it honors the project's tsconfig, the JSX automatic runtime, and module resolution fallbacks). On errors it returns core.formatReport()'s human-readable report (file:line:col TS#### reason + a code frame). Errors stop it here: the tool returns normally, but does not start the service and does not take a screenshot — see "why the type check comes first" below;
  2. Serve (reused if already running, no recompile);
  3. Screenshot — asks the sidebar to open the tab so the app page draws its own DOM into a PNG; the image is returned as an image block in the tool result, and the report only keeps the dimensions and size.

Why the type check comes first: it is the only step that "spawns nothing, touches no browser, and answers in tens of milliseconds", and its conclusion decides whether the next two steps are worth doing. Doing it the other way round runs into two problems: an app with type errors still starts (Vite does not type check), but the screenshot is a full-screen error overlay — and an agent easily mistakes the overlay for the page and concludes "rendering is fine"; and every wrong edit costs a wasted compile plus a wasted screenshot.

The report's order is the actual order of events: file: → type check: → status: / preview: → screenshot:. When the type check fails the last two segments do not appear (if this app happened to be running already, one extra line preview: … (already running — not restarted) shows up so it is not mistaken for the result of this call).

Why fold the type check in here instead of keeping a separate tool: the agent's real action is always "edit → take a look at whether it is right", and a type error is just one kind of "not right". Split into two tools, it has to remember which one comes first; merged into one, it only remembers one thing.

The image goes back to the model: the canonical value is a report plus (when a capture happened) a reference to the image, and output.render turns the latter into an ImageBlock in the same content. An image block only accepts a durable attachment reference — there is no inline base64 path — so execute hands the PNG to ctx.attachments.saveImage() first and builds the block from the attachmentId that comes back. The screenshot is not written to disk: piling PNGs into %TEMP% just leaves litter on the user's machine. That is also why tools and attachments are requested in the same ctx.inject([…]) — without attachment storage this tool has no way to let the model see anything.

A failed screenshot does not fail the tool: when the sidebar is not open or the browser does not support it, the type check and the service URL are still valid, the report says screenshot: unavailable — …, and no image block is attached (no empty reference to fool the model). Turning the whole call into an error would just waste a round trip for the agent.

How the screenshot gets through

The tool lives in the host process, screenshots can only be taken in the browser, and the two have no persistent connection — a mailbox (src/shots.ts) connects them:

tool request(path) ──► mailbox (id, path, promise)
                        │
   client long-polls GET /tsxapp/shots ──► sees the id ⇒ sidebarRight.openResource(url) opens the tab
                        │
   pane body polls its snapshot, sees shots:[id] ──► postMessage to host page ──► app page domToPng
                        │
   POST /tsxapp/shot { id, dataUrl } ──► settle(id) ──► the tool's promise resolves
  • There must be a timeout (previewTimeoutMs, default 20s): the tab may never be opened, and then nobody will come for this letter;
  • One screenshot per path: a later request reuses the one in flight;
  • Cleared on unload: when the plugin is unloaded, everything waiting resolves as a failure, so the tool's promise never hangs forever.

The capture itself lives in @tsxapp/runtime's captureScreenshot() (domToPng from modern-screenshot, dynamically import()ed so it does not weigh on first paint): the longest edge is capped at 1600, over 3 MB steps down progressively, and the background color is taken from the root node's actual background (otherwise a dark theme screenshots into white background with black text).

What the three window layers say to each other

DSH sidebar (parent window, possibly file://)
   └─ iframe ─► host page  localhost:P   (the only holder of the token)
        └─ iframe ─► app page  app.localhost:P  (cross-origin, runs user code)
  • Sidebar ⇄ host page: { __tsxapp: 1, t: 'screenshot', id } goes down, screenshot-result comes back; log / error go up (the host page's #postToParent in bridge.ts). Only these three kinds flow, no token, no credentials;
  • Host page ⇄ app page: the original RPC line, with the screenshot command and result as the two newly added message types.

There is a deliberate trade-off in the implementation: defineTool() is not imported. It is runtime code, and pulling it in would inline the whole schema compiler into dist/index.js (60 KB → 400 KB), while its only contribution here is compiling the parameter spec into a JSON Schema — writing it by hand is smaller. The price is that parameters are not schema-validated, so limitOf / resolveTarget check every field themselves. @deepseek-ai/dsh-tools is kept only as a type source (devDependency).

Registration goes through ctx.inject(['tools'], …), not a direct ctx.tools.register(). There is exactly one reason: cordis's ctx is a Proxy, and reading a service that is not in the plugin's inject throws on the spot with cannot get property "tools" without inject — ?. does not catch it (the exception happens while fetching the property), and this throw aborts the whole apply — the six routes and the sidebar all fail to activate together, and DSH only reports "1 entry did not activate". Putting tools into the plugin's inject is too heavy the other way: it is only an optional service, and its absence should not make the whole plugin wait. ctx.inject wins on both counts: it runs the callback once the service is ready, re-runs it if it arrives late, and if it never appears you just have one tool fewer.

Styles are injected once via <style>, and the test is content, not "is the element there" — ensureStyles skips only when the copy in the page is byte-for-byte identical to the current version. A "return if the id exists" version would mean that after a plugin upgrade (re-mounting the plugin, no full page reload) not one new rule takes effect, the page keeps the previous version's styles, the new class names have no styles and the buttons fall back to browser defaults — which looks exactly like "the layout blew up".

The service lives with the tab, not with the tab's visibility: the tab type declares keepMounted: true — by default the host only keeps visible pane bodies in the tree, so switching to another tab, collapsing the sidebar or changing sessions unmounts the body, and unmounting immediately fires a stop request (this is precisely the old "switch a tab and the app is gone"). keepMounted keeps a visited body mounted while hidden; only really closing the tab unmounts it.

Closing the tab stops the service: useRun sends a POST /tsxapp/stop on unmount. Waiting 260 ms is to avoid React StrictMode's "mount → unmount → mount again" — sending immediately would kill the service that was just started. When the body is hidden (not unmounted) only status polling pauses; the service keeps running.

Why the page can be framed: the host page does not set frame-ancestors — who may frame it is decided by the token. To render, the URL must carry ?t=…; no other page can obtain or guess it, and framing without it gives you an empty shell saying "missing access token". An allowlist would lock you out instead: browsers validate the whole ancestor chain (DSH's localhost:3080 → host page → app page — miss one link and it is a blank page), and in Electron the page origin is "null", which no host-source can match. So the plugin does not need to report its own origin either.

Pressing "start" twice on the same file does not start a second service; closing the tab, clicking stop and unloading the plugin are three paths that all close() the service. On a start timeout (20s by default), if the core does come up later, the plugin closes it immediately — no port left unclaimed.

Host routes

Six of them, all passing through the connection layer's trust fence (ctx.connection.requestRejection) first; anything undeclared is refused:

MethodPathPurpose
POST/tsxapp/start{ address } → start the service, return a status snapshot
POST/tsxapp/stop{ address } → close the service
GET/tsxapp/state?address=…status + logs + pending screenshot ids (shots)
GET/tsxapp/source?address=…source (≤ 512 KB, with truncated)
GET/tsxapp/shotsall screenshot requests the host is still waiting on (client long-polls this to open tabs)
POST/tsxapp/shot{ id, dataUrl, width?, height? } → resolves the promise the tool is waiting on

address is a DSH resource address, dsh-resource://file/session/<sessionId>/<path> or …/absolute/<path>. The session scope is resolved by the host into an absolute path based on that session's cwd — the browser half neither knows nor needs to know where the workspace is. Only .tsx is accepted.

The client requests using relative paths (tsxapp/state, no leading slash): on desktop the page runs on file:// and requests go through the IPC bridge, where a leading slash gets parsed as a drive root.

Where the skill content comes from

The skill/ directory inside the package is the only copy; there is nothing to sync. It plays two roles at once:

  • src/skill.ts statically imports these three .md files, inlined into string constants at build time (the body has nothing to do with disk);
  • resourceBase points at this directory, so when the model reads references/*.md on demand it goes through real files.

So to change the skill, edit skill/ directly — both the build and the published package read from it.

The plugin appends a DSH-specific note after the generic skill body (save as *.app.tsx, let the sidebar start it, do not run commands yourself), see SKILL_APPENDIX in src/skill.ts. The skill body only covers how to write an app; how to run things (CLI flags, the panel, debugging) stays out of the skill and lives in this document and the root README.

Dependencies

This package is an ordinary workspace member, and its two kinds of dependencies each do one job:

DependencyProvided byUsed for
@tsxapp/core ("workspace:*")pnpm links it to packages/corecalling startApp() at runtime
@deepseek-ai/cordis + @deepseek-ai/dsh-{host-webserver,skill,session,client-connection} (devDependencies)this repo's pnpm installtypes for the host half, all erased after compiling
@deepseek-ai/dsh-client-ui-{sidebar-right,renderer,slots} + @types/react (devDependencies)sametypes for the browser half (slot names, useTabInfo, tab definitions), also compile-time only
@deepseek-ai/dsh-client-ui-primitives (devDependencies)sameToast / Modal / Button for the error banner and details modal: types come from here, and at runtime require resolves to the copy in the host's module table

The @deepseek-ai versions track the DSH desktop release (currently 0.2.0-rc.2, cordis 4.0.5). If a service signature changes after a DSH upgrade, pnpm dsh:typecheck reports it first — at that point sync the versions or change the usage, rather than coming back and copying the types to work around it.

These devDependencies do not travel when installing into a DSH profile: the host already has its own copy, and installing another would just create a second instance. The same goes for React: the package has no react, only its types, and the runtime require('react') is answered by the host's module table.

Self-tests

pnpm dsh:test    # = pnpm dsh:build && vitest run --project dsh

The unit tests are vitest (test/plugin.test.ts, TypeScript) with the config in the package's own vitest.config.ts; running pnpm test inside the package runs just this one.

There is no real host; stubs bring both halves up:

  • Host half: feed a fake ctx and then really call those six routes. Starting is swapped for the stub core in test/stub-core.ts, so the "start → get a handle → running → stop closes the handle" chain really runs, and the failure / timeout behaviors of the core can be played too. The tool section verifies the screenshot loop as well: hang a request → see the id → POST /tsxapp/shot with a fake PNG → the report contains the path on disk. The plugin uses static imports and has no "swap the core" switch, so the stub is installed at the module resolution layer: resolve.alias in vitest.config.ts points the package name @tsxapp/core at the stub — test infrastructure, not one line of which is visible in the output.
  • Real core: the end-to-end case has the stub forward startApp to the real @tsxapp/core, really starting a Vite service, fetching the host page once to confirm 200, then confirming the port is no longer reachable after stop; another case uses the real tsc to check typecheckApp, and one more reads the output directly to pin down "it is a top-level static import and there is no core entry in the config". This is the acceptance point for "does not call the CLI".
  • Browser half: stubs window.__ModuleLoader__ to intercept the bundled dist/client.js and check the registered kind / patterns / priority / title and the pane body slot; then verifies "someone is waiting for a screenshot ⇒ open a tab at the session address".

What is tested is the dist output, not the source: those two files are what DSH actually loads, and the case "types are fine in source but it does not run once bundled" can only be caught at this layer.

Known limitations

  • Only useful in the web profile (TUI / headless have no sidebar).
  • A tab's title is frozen to the file name when it opens; renaming the file does not update already-open tabs.
  • One service per .app.tsx, shared within the process; the same file opened in two tabs shares one state.
  • The port is incremented by the core (port is only a starting value); the plugin just displays the final address truthfully.
  • The service runs in the same process as DSH: if DSH crashes the service goes with it (that is the semantics of "embedded", but it also means no process isolation).
same instances
PLATFORM_MODULES
client/web/src/seed.ts
react-dom