dsh-fluxion
v0.0.4
Published
dsh-fluxion — a monochrome microkernel desktop for the DeepSeek Harness: free windows, a shared AI terminal, pluggable APPs.
Maintainers
Readme
dsh-fluxion
A monochrome AI-development cockpit for the DeepSeek Harness Web GUI: a microkernel desktop on DeskPane where every panel is a pluggable APP, plus a shared terminal that the AI and you take turns driving.
npm i -g @deepseek-ai/dsh # if you do not have the CLI yet
dsh plugin --profile web add dsh-fluxionThen reload the GUI. The package declares dsh.bundle.patch, so the install also
inserts its loader row — no manual YAML.
If you install it by hand instead, add the row yourself:
- insert:
- id: dsh-fluxion
name: dsh-fluxionThe package ships exactly that row in its own cordis.patch.yml. The DSH Desktop
app has no dsh plugin path while it is running, so there the row is added to
%DSH_HOME%\profiles\desktop\cordis.patch.yml once, by hand.
Once loaded, the desktop opens over the stock interface; 退出 Flux in the rail header puts the stock GUI back and remembers the choice, so the next launch is yours to decide.
What works today
| APP | Default | State |
|---|---|---|
| 终端 | tiled, open | Real PTY over remote.terminal.*: create → follow → write → close. 打断 sends Ctrl+C; 强杀 closes the process group. |
| 代码编辑器 | tiled, open | Editable buffer, VS Code-style tabs, line numbers, centred column, completion popup. Monaco later. |
| AI 对话 | tiled, open | Sends into the current DSH session through ctx.conversation.send, so replies land in the real conversation. |
| Activity Monitor | tiled, open | AI commands / file changes as a live log with filter, follow-tail, and double-click edit. Seeded demo rows until the Host event bridge exists. |
| 节点工作流 | disabled | Real graph: add/delete nodes, connect, rewire, Kahn topological dry run. Off by default; the rail switch turns it on. |
| Live2D Stage | closed | Reserved seam for the separate dsh-flux-live2d plugin. |
Boot lands on the desktop, not the stock chat — "开启即 Flux" — while the stock GUI stays one click away and the preference is remembered.
Layout
There is no slot system. Slots needed occupancy tables, reflow, and conflict resolution, and produced more bugs than order. Instead:
gridRects()lays the startup windows out once, then stops meddling.- Windows are free: drag, resize, close, whoever wants.
- Snapping is decided by the pointer position at drag start/end — left/right half, four quarters, top-maximize — not by the window rect. DeskPane clamps dragged windows inside the canvas, so a rect-based edge test is unreachable.
- Released in the middle → the window stays put; it does not bounce back.
The rail collapses to icons; expanding it is an overlay above the canvas, so it never squeezes the windows beside it. The bottom panel sits under the canvas in normal flow.
Build from source
npm install
npm run typecheck
npm run build # DSH plugin → lib/
npm run build:preview # standalone demo → preview/preview/index.html is self-contained (no server). It renders the same shell
with the kernel's local stand-ins, so you can look at the UI without a Host.
Build contract (important)
DSH loads each browser plugin as a classic script that registers itself:
window.__ModuleLoader__.load({ id: 'dsh-fluxion', factory: (require) => { /* … */ } })Dependencies (react, react-dom, react/jsx-runtime) come from the injected
require. A plain ESM bundle is loaded as a script, its import statements
cannot resolve, and DSH reports dsh-fluxion: import failed. tsdown.config.ts
emits CJS for the client half and wraps it in that envelope; do not "simplify"
it back to ESM.
src/styles/mono.ts is generated from flux.css by scripts/sync-css.mjs
on every build, because the client bundle has no CSS pipeline. That script also
strips a leading BOM: a U+FEFF byte turns .flux-shell into \uFEFF.flux-shell,
which silently disables the entire stylesheet.
Architecture
src/
index.ts DSH Host entry (no-op)
client.tsx browser entry: shell.overlay + session probe
preview.tsx standalone preview entry
core/
types.ts FluxApp / FluxAppProps contract
kernel.ts registry + windows + activity + bridges
event-bus.ts typed domain bus
activity-store.ts AI-activity ring buffer
hooks.ts useApps / useOpenWindows
register-apps.tsx built-in APP catalogue
components/Tabs.tsx reusable VS Code-style tab strip
shell/
FluxRoot.tsx kernel lifetime; publishes it for other plugins
FluxDesktop.tsx DeskPane + portals + rail + bottom panel
snap.ts grid math + pointer-based edge targeting
window-geometry.ts setBounds shim over DeskPane internals
apps/
terminal/ driver (who may type) + pty (the bytes)
chat/ session-bound prompt sender
editor/ tabs, tokenizer, diagnostics, completion
graph/ monitor/ live2d/
plugins/live2d-placeholder.tsx
styles/ flux.css → mono.ts (generated) → install.tscore/ imports no React components; apps/ never import DeskPane directly.
APPs receive { kernel } and reach the desktop only through it.
Adding an APP
One call, no shell edits:
kernel.apps.register({
id: 'bilibili',
title: 'B站播放器',
icon: '▷',
hint: '悬停说明,也是侧栏副标题',
component: BilibiliApp,
placement: 'floating', // or 'tiled'
openByDefault: false,
});The rail entry, the window, and portal mounting all derive from that record.
register returns a disposer that unregisters the APP and closes its window.
Shared terminal: the rules
The terminal is one PTY with two possible drivers. TerminalDriver owns who
may type; pty.ts owns the bytes.
- States are 空闲 / AI 执行中 / 你。The header always shows which.
- You always win. A keystroke hands you control immediately.
- The AI can never take it back. It must ask, or wait for the idle timer.
- Idle 1 minute hands control back to the AI — but only when no user process is
in the foreground; a running
npm testyou started is not "idle". - AI input is written as whole atomic lines, never partial keystrokes.
- A command refused because you took over is reported to the AI as not run, never as a result.
Ctrl+Cis the interrupt; 强杀 closes the process group. Both are buttons.- Your commands are fed back to the session as command + exit code + last 20 lines, truncated.
Terminal wire contract (verified against dsh-api-terminal-controller):
environment(sessionId, signal) -> RemoteResult<TerminalEnvironment>
create(sessionId, {id, cols, rows}) -> RemoteResult<WebTerminalInfo>
follow(sessionId, id, attachmentId, signal) -> AsyncIterable<TerminalFrame>
write(sessionId, id, attachmentId, data) -> RemoteResult<void>
close(sessionId, id) -> RemoteResult<void>Every non-stream call resolves to {ok:true,value} or {ok:false,error}, so the
caller must unwrap. Frames are snapshot (full screen), output (incremental
bytes) and state (process info). create rejects dimensions above
environment().maxCols/maxRows, which is why the bridge asks first. We drive the
Remote namespace directly instead of ctx.webTerminals.view() because a view
binds an xterm emulator and requires acknowledging every render frame; these
panes render plain text.
Session identity
shell.overlay runs at root scope and therefore has no Session. Terminals,
prompt sending and model switching all need a sessionId, so the plugin also
registers an invisible, session-scoped probe in
conversation.session.header.actions; it reports the active session into the
kernel. Without a session the terminal says 未绑定会话 and refuses to pretend.
Live2D: separate plugin
plugins/live2d-placeholder.tsx registers the stage so the seam is visibly
reserved. The real package replaces that one call:
// dsh-flux-live2d
export function register(kernel: FluxKernel) {
return kernel.apps.register({ id: 'live2d.stage', /* ... */ });
}The kernel is published as window.__dshFluxKernel for exactly this case: a
separately built plugin cannot import this module instance, so it reads the
kernel from that key. Nothing in the shell, rail, or window manager changes.
Windows
Rail click toggles a window. DeskPane provides drag, edge resize, maximize,
minimize, and edge snapping; React content is portalled into the window body.
deskpane exports no React adapter and no public programmatic
setBounds/unmaximize — window-geometry.ts writes its internal state and
verifies the private shape at runtime, falling back to style-only writes.
Style
Pure black/white/grey — no hue anywhere, including DeskPane's snap guides, which
are overridden to monochrome. Colours live in --flux-* tokens, so a theme
preset is a token swap rather than a rewrite. The boot animation is reserved for
the same layer.
Not implemented (and not faked)
- Host event bridge: the DSH remote allowlist exposes no raw terminal/file events, so the Activity Monitor's live rows are seeded demo data.
- File tree and md/image/video preview: needs a Host file bridge first.
- AI background terminal, git UI,
/plan /spec /goal, in-app browser console → AI, Ctrl+Alt+↑/↓ multi-cursor, theme presets, boot animation.
See docs/backlog.md for the full list and docs/dsh-plugin-contract.md for the
hard-won plugin contracts.
