dsh-oc-tui 0.1.4 → 0.2.0-pre.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -28
- package/bin/apply-update.cjs +187 -0
- package/bin/mcp-sse-bridge.mjs +499 -0
- package/cordis.patch.yml +46 -32
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +560 -424
- package/dsh-plugin.json +65 -65
- package/lib/caps.js +83 -0
- package/lib/i18n.js +283 -0
- package/lib/image.js +326 -0
- package/lib/index.js +980 -185
- package/lib/locales/ar.js +561 -0
- package/lib/locales/de.js +530 -0
- package/lib/locales/en.js +548 -0
- package/lib/locales/es.js +530 -0
- package/lib/locales/fr.js +530 -0
- package/lib/locales/id.js +532 -0
- package/lib/locales/it.js +530 -0
- package/lib/locales/ja.js +530 -0
- package/lib/locales/ko.js +530 -0
- package/lib/locales/nl.js +530 -0
- package/lib/locales/pl.js +544 -0
- package/lib/locales/pt-BR.js +530 -0
- package/lib/locales/ru.js +544 -0
- package/lib/locales/tr.js +530 -0
- package/lib/locales/vi.js +532 -0
- package/lib/locales/zh-CN.js +531 -0
- package/lib/locales/zh-TW.js +531 -0
- package/lib/markdown.js +560 -297
- package/lib/mcp-skill.js +93 -0
- package/lib/mcp.js +838 -0
- package/lib/mermaid-ascii.js +138 -0
- package/lib/mermaid.js +137 -0
- package/lib/rewind.js +15 -14
- package/lib/splash-opencode.js +108 -0
- package/lib/startup.js +16 -10
- package/lib/term.js +242 -10
- package/lib/ui.js +1265 -406
- package/lib/updates.js +1028 -697
- package/lib/util.js +85 -27
- package/lib/web-settings.js +846 -454
- package/package.json +145 -100
- package/skills/mcp-import/SKILL.md +216 -0
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# dsh-oc-tui
|
|
2
2
|
|
|
3
|
+
**The `0.2.0` line is the visual-experience update, and it is out as a pre-release now** (`0.2.0-pre.11`, on npm's `next` channel): real image rendering, native Mermaid art, and full Markdown run through this plugin's own terminal engine. It needed no `chafa`, `mmdc` or `mdcat` wrapper — the rendering stack is pure npm (`sharp` decodes and scales, `lovely-mermaid` lays out Mermaid) — and which dsh (the harness) builds it serves is spelled out in the compatibility note below. If you have suggestions for specific visual features, please share them in the original issue thread or open a new issue with a title containing `Visual Experience Suggestion`
|
|
4
|
+
|
|
5
|
+
**_Thank you for your support of the `dsh-oc-tui` plugin!_**
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
3
9
|
**A terminal UI for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)** — an opencode-inspired chat client that boots inside the `dsh` process as a profile app plugin.
|
|
4
10
|
|
|
5
11
|
[](https://www.npmjs.com/package/dsh-oc-tui)
|
|
@@ -7,7 +13,7 @@
|
|
|
7
13
|
[](LICENSE)
|
|
8
14
|
[](https://nodejs.org)
|
|
9
15
|
|
|
10
|
-
`dsh-oc-tui` renders the harness's durable event stream in your terminal — streaming replies, tool
|
|
16
|
+
`dsh-oc-tui` renders the harness's durable event stream in your terminal — streaming replies, tool activity, todo lists, thinking blocks — and routes what you type back into the agent. Model routing, tool execution, approvals, commands, durable sessions, and credentials stay owned by DSH; this package owns terminal input and presentation.
|
|
11
17
|
|
|
12
18
|
Published on **npm** as [`dsh-oc-tui`](https://www.npmjs.com/package/dsh-oc-tui) and listed in the [**awesome-dsh-plugin**](https://awesome-dsh-plugin.com/zh/p/rayafriandion/dsh-oc-tui/) marketplace.
|
|
13
19
|
|
|
@@ -25,7 +31,10 @@ Published on **npm** as [`dsh-oc-tui`](https://www.npmjs.com/package/dsh-oc-tui)
|
|
|
25
31
|
- [Interactive prompts](#interactive-prompts)
|
|
26
32
|
- [Thinking intensity](#thinking-intensity)
|
|
27
33
|
- [Session stats and the context meter](#session-stats-and-the-context-meter)
|
|
34
|
+
- [The status rail](#the-status-rail)
|
|
28
35
|
- [Settings](#settings)
|
|
36
|
+
- [Language](#language)
|
|
37
|
+
- [MCP servers](#mcp-servers)
|
|
29
38
|
- [In-app updates](#in-app-updates)
|
|
30
39
|
- [How it works](#how-it-works)
|
|
31
40
|
- [@dsh-std interop](#dsh-std-interop)
|
|
@@ -41,14 +50,21 @@ Published on **npm** as [`dsh-oc-tui`](https://www.npmjs.com/package/dsh-oc-tui)
|
|
|
41
50
|
| --- | --- |
|
|
42
51
|
| **Durable sessions** | Create, resume, list, and delete sessions; the transcript is rebuilt from the persisted event log, so a resumed session looks exactly like the one you left. |
|
|
43
52
|
| **Live streaming** | Assistant text and reasoning stream token by token; thinking renders in its own collapsible box that stays collapsed while streaming. |
|
|
44
|
-
| **
|
|
53
|
+
| **Real image rendering** | Images in the transcript render as real pixels — kitty graphics, iTerm2 inline images, or sixel, probed from the terminal itself (works over SSH), with a truecolor half-block preview on terminals that support no protocol. Pasted images, stored attachments, and markdown images all flow through one pipeline. See [Graphics rendering](#graphics-rendering). |
|
|
54
|
+
| **Mermaid diagrams** | A ` ```mermaid ` fence renders as a real diagram through a provider chain — local `mmdc` if installed, otherwise mermaid.ink (gated by a setting) — into the same image pipeline; offline it degrades to highlighted source. |
|
|
55
|
+
| **Full markdown** | Tables in rounded boxes, nested and task lists, syntax-highlighted code (190+ languages), and clickable OSC 8 hyperlinks, rendered from markdown-it's CommonMark + GFM token stream. |
|
|
56
|
+
| **Flat transcript** | No frames around the conversation: your turns open with a brand-blue `❯` pointer and hang under it, the assistant answers as a full-width column of text under its `dsh · time` label, and every block measures itself in text — so wide runes never push a border out of alignment. The header is one row: brand and session title on the left, the workspace path with its git branch on the right; the model is not repeated there (the status row / rail names it). |
|
|
57
|
+
| **Tool activity** | One row per tool: a status mark, the action in the tool's own accent (`read` cyan, `run` gold, `edit` light blue, `todo` mint), and its target. Click the row to expand the full result under a `⎿` gutter. |
|
|
45
58
|
| **Interactive questions** | The model can pause and ask you — option lists, multi-select, free text, and a scrollable plan review — all inline in the terminal. See [Interactive prompts](#interactive-prompts). |
|
|
46
59
|
| **Inline approvals** | `approval/request` prompts are answered with `y` / `n` without leaving the UI; the box shows the action and the reason, with room for origin, risk and details when a `@dsh-std` request carries them. |
|
|
47
60
|
| **Session stats** | One stats strip above the composer — turns/steps, LLM and tool wall time, average TTFT, decode throughput, cache-hit rate, and billed input/output tokens — folded from durable events. See [Session stats and the context meter](#session-stats-and-the-context-meter). |
|
|
48
61
|
| **Stats window** | Click the strip or the context meter, or type `/stats`, for the full session-statistics and token-usage breakdown. |
|
|
49
62
|
| **Context meter** | Live context occupancy (`ctx ▓▓░░ 32K/128K 25%`), with the system/tools/messages composition in the same window. |
|
|
63
|
+
| **Status rail (wide windows)** | From 140 columns a right-hand rail carries the expanded status — model, thinking intensity, context, session figures — plus the session's current goal (`/goal`) and its subagent activity, scrollable with `Shift+↑/↓` or the wheel. Narrow windows keep the bottom status row. See [The status rail](#the-status-rail). |
|
|
50
64
|
| **Thinking intensity** | `Tab` cycles the current model's real reasoning levels; `Ctrl+E` opens a slider. The level is applied per request and persisted. |
|
|
51
65
|
| **Shared settings** | The same host settings namespaces the Web UI uses — general, sessions, per-provider model configuration, credentials — persisted to `$DSH_HOME/settings.yaml`. |
|
|
66
|
+
| **17 languages** | Every string the TUI draws — chrome, transcript, dialogs, toasts, command help — comes from a catalog, so the whole interface follows one setting: `Ctrl+P → Language`, or the environment when no preference is stored. English, Chinese (simplified and traditional), Japanese, Korean, German, French, Spanish, Portuguese (Brazil), Italian, Russian, Polish, Dutch, Turkish, Vietnamese, Indonesian and Arabic. See [Language](#language). |
|
|
67
|
+
| **MCP servers** | A `Ctrl+P → MCP` tab manages MCP servers from the TUI itself: add or edit `stdio` / `HTTP` / `SSE` servers, remove them, and watch each connection state with the tools it registered. Scope is per user or per project directory, and the bundled `mcp-import` skill lets the model add servers for you. MCP is optional — with no server, a failing server, or no MCP client in the host at all, the TUI runs exactly as before. See [MCP servers](#mcp-servers). |
|
|
52
68
|
| **In-app updates** | Detect and switch versions of `@deepseek-ai/dsh` and `dsh-oc-tui` from inside the TUI, with Windows-safe deferred installs. |
|
|
53
69
|
| **Zero-dependency terminal engine** | Raw mode, alternate screen, a diffing cell buffer, truecolor ANSI, CJK-aware widths, SGR + legacy X10 mouse decoding, and IME caret anchoring. |
|
|
54
70
|
|
|
@@ -67,7 +83,16 @@ dsh --version
|
|
|
67
83
|
pnpm --version
|
|
68
84
|
```
|
|
69
85
|
|
|
70
|
-
**Compatibility.** Verified against dsh `0.1.2-rc.1` (
|
|
86
|
+
**Compatibility.** Verified against dsh `0.2.0-rc.2` and `0.1.2-rc.1` (`0.1.1-rc.2` before that). DSH renamed parts of the session API in 0.1.2 — `Session.events` became `snapshotEvents()` — and this plugin reads whichever accessor the host provides, so one build serves both lines.
|
|
87
|
+
|
|
88
|
+
**dsh (the harness) 0.2.0 is a breaking update — upgrade this plugin first.** From dsh `0.2.0-rc.1` the agent-preset packages were split: the roster provider `@deepseek-ai/dsh-agent-presets` (which shipped its own presets) became `@deepseek-ai/dsh-agent-preset-registry`, and a new `@deepseek-ai/dsh-agent-preset` package is only a *declarative* preset row that injects the service the registry provides. A TUI older than `0.1.5` names the old provider, and dsh reports it at every boot:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
dsh: disabling profile plugin row "agent-presets": Plugin @deepseek-ai/dsh-agent-presets@0.1.1-rc.2 is
|
|
92
|
+
incompatible with dsh 0.2.0-rc.2: peerDependencies {…}. Running it may cause crashes or data loss.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The TUI still starts, but Settings → Default preset loses its roster (the row is hidden once no provider mounts it). Updating `dsh-oc-tui` to `0.1.5` or later removes the report: this bundle mounts no roster row at all, because no single package name works on both lines — the old one warns on dsh 0.2.0, the singular never activates, and the registry activates empty unless a host declares presets. A dsh 0.2.0 profile that wants a roster mounts the registry plus its own preset declarations, exactly as `dsh-web-app` does. Nothing about your sessions changes in either direction; `cordis.patch.yml` documents the same decision next to the rows.
|
|
71
96
|
|
|
72
97
|
## Install
|
|
73
98
|
|
|
@@ -87,17 +112,24 @@ npm install -g dsh-oc-tui
|
|
|
87
112
|
|
|
88
113
|
### Version channels
|
|
89
114
|
|
|
90
|
-
|
|
115
|
+
npm carries two channels for this package: **`latest`** holds the stable releases, **`next`** the pre-releases (`0.2.0-pre.x`, `-rc`, `-alpha`, `-beta`). Untagged installs — `dsh plugin … add -w dsh-oc-tui` and `npm install -g dsh-oc-tui` above — therefore still give you the newest stable version, never a release candidate. The [awesome-dsh-plugin](https://awesome-dsh-plugin.com/zh/p/rayafriandion/dsh-oc-tui/) marketplace entry links to the same package, so an install started from it lands on the stable channel too.
|
|
91
116
|
|
|
92
|
-
|
|
117
|
+
To run a pre-release, name the tag: `dsh plugin` forwards the spec to pnpm untouched, and pnpm resolves dist-tags.
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
dsh plugin --profile tui add -w dsh-oc-tui@next # newest pre-release
|
|
121
|
+
npm install -g dsh-oc-tui@next
|
|
122
|
+
```
|
|
93
123
|
|
|
94
|
-
|
|
124
|
+
Unreleased work in this repository carries no dist-tag at all, so install it from a packed tarball or a checkout (see [From a checkout or tarball](#from-a-checkout-or-tarball)):
|
|
95
125
|
|
|
96
126
|
```sh
|
|
97
127
|
npm pack # -> dsh-oc-tui-<version>.tgz
|
|
98
128
|
dsh plugin --profile tui add -w ./dsh-oc-tui-<version>.tgz
|
|
99
129
|
```
|
|
100
130
|
|
|
131
|
+
This README describes the current source tree, which can be ahead of the published release — a feature documented here is only guaranteed to exist in a stable build once that version is on npm.
|
|
132
|
+
|
|
101
133
|
### One-command installers
|
|
102
134
|
|
|
103
135
|
The repository ships installers that check Node >= 22, make sure `pnpm` exists, install the plugin into the `tui` profile, and can also add the `dsh-oc-tui` launcher globally.
|
|
@@ -140,7 +172,7 @@ Verify without booting:
|
|
|
140
172
|
dsh --profile tui --dump-config
|
|
141
173
|
```
|
|
142
174
|
|
|
143
|
-
The dump shows a `# == dsh-oc-tui` layer containing `tui-startup`, `tui-app`, the `
|
|
175
|
+
The dump shows a `# == dsh-oc-tui` layer containing `tui-startup`, `tui-app`, and the `tool-ask-user` row — three rows, no agent-preset one (see the compatibility note above).
|
|
144
176
|
|
|
145
177
|
## Quick start
|
|
146
178
|
|
|
@@ -197,13 +229,14 @@ It prefers the `dsh` on `PATH` and falls back to `npx --yes @deepseek-ai/dsh`. I
|
|
|
197
229
|
| `Ctrl+D` | In Settings → Manage sessions: delete the focused session (press twice to confirm). |
|
|
198
230
|
| `Ctrl+L` | Clear the transcript view. |
|
|
199
231
|
| `Up` / `Down` | Move the caret across a multi-line prompt; on the first/last row, step through input history. |
|
|
232
|
+
| `Shift+Up` / `Shift+Down` | Scroll the status rail (wide windows); without the rail they act as `Up` / `Down`. |
|
|
200
233
|
| `Left` / `Right` | Move the caret within the input box. |
|
|
201
234
|
| `PgUp` / `PgDn` | Scroll the transcript. |
|
|
202
235
|
| `Esc` | Close the session stats window, the thinking slider, or help; cancel an approval; cancel a running turn; clear the prompt you are typing. |
|
|
203
236
|
| `Esc Esc` | Idle with an empty prompt: open the rewind picker. |
|
|
204
237
|
| `y` / `n` | Answer an inline approval prompt. |
|
|
205
238
|
|
|
206
|
-
**Mouse.** The wheel scrolls the transcript
|
|
239
|
+
**Mouse.** The wheel scrolls the transcript — or the status rail when the pointer rests over it, or the Settings window while it is open. Hold the left button and drag across the transcript to select text, then press the right button to copy the selection.
|
|
207
240
|
|
|
208
241
|
### Slash commands
|
|
209
242
|
|
|
@@ -267,15 +300,88 @@ The whole strip is a click target. Clicking it — or the context meter at the r
|
|
|
267
300
|
|
|
268
301
|
Figures come from the same sources as the Web UI, projection-first with the plugin's own fold as the fallback: `tokenUsage`, `contextPressure`, and `contextBreakdown` are mounted by `dsh-base`'s token-meter row, while `sessionStats` is mounted only by the web app bundle — so the TUI folds the durable `step` / `chunk` / `message` / `tool` events by the same rules. A missing projection falls back for that figure alone, and a figure nobody can supply stays hidden instead of printing a zero.
|
|
269
302
|
|
|
303
|
+
### The status rail
|
|
304
|
+
|
|
305
|
+
From **140 columns** a right-hand rail takes over the status figures, OpenCode-style: the session title pins to the top, scrollable sections fill the middle — `MODEL` (model, provider), `THINKING` (current level and its real range), `CONTEXT` (occupancy bar, tokens, billing, composition), `SESSION` (the stats strip's figures, expanded), `GOAL`, `SUBAGENTS` — and the running builds (`dsh x.y.z · tui a.b.c`) pin to the bottom. Sections with no data drop out whole.
|
|
306
|
+
|
|
307
|
+
`GOAL` and `SUBAGENTS` read the harness's native surfaces: the `goal` projection registered by `dsh-goal` (objective, phase, rounds, blocker reason), and `ctx.subagents.listDescendants()` for the delegation tree rooted at the open session, refreshed on the scoped `subagent/start` / `subagent/end` edges. Both services are optional — a composition without them leaves those sections empty.
|
|
308
|
+
|
|
309
|
+
When the sections outgrow the window a scrollbar appears in the rail's right edge; `Shift+↑` / `Shift+↓` or the wheel over the rail scrolls it. Clicking the `CONTEXT` section opens the session stats window (the seat the bottom-row meter vacates). Below 140 columns the rail disappears and the pre-rail layout — model on the status row, the stats strip above the composer — returns unchanged.
|
|
310
|
+
|
|
270
311
|
### Settings
|
|
271
312
|
|
|
272
313
|
`Ctrl+P` opens a settings menu over the same host settings namespaces as the Web UI, persisted through `ctx.settings` to `$DSH_HOME/settings.yaml`. A left menu splits it into three tabs (`Tab` or click to switch):
|
|
273
314
|
|
|
274
|
-
- **Main** — General (busy-Enter behaviour,
|
|
315
|
+
- **Main** — General (busy-Enter behaviour, permission preset), Sessions (new session, manage sessions), System (provider API hints, update-manager shortcut, settings file path).
|
|
275
316
|
- **Model** — the default provider/model/reasoning choice, then one group per provider holding its URL, API key, and model list. Pressing `Enter` on **Models** fetches the provider's advertised catalog (`ctx.llm.discoverModels`) and opens a checkbox window; pressing `Enter` on a listed model makes it the default route.
|
|
317
|
+
- **MCP** — see [MCP servers](#mcp-servers).
|
|
276
318
|
- **Update** — see [In-app updates](#in-app-updates).
|
|
277
319
|
|
|
278
|
-
Only providers you actually added (present in your user settings layer) are listed; a provider that was never added stays hidden.
|
|
320
|
+
Only providers you actually added (present in your user settings layer) are listed; a provider that was never added stays hidden. **Default preset** is shown only when the profile mounts an agent-preset roster, because the choice list is that roster — and this plugin deliberately mounts none (see the compatibility note above), so on a stock `tui` profile the row is absent rather than empty. Where a roster *is* mounted, note that a TUI session composes process-wide from the base, so the stored default applies where a session is created from a preset. Web-UI-only options (`ui-theme` appearance, the Web UI's `locale`) are not shown because they have no effect in the TUI; the TUI's own **Language** row is a separate namespace (`tui-locale`) — see [Language](#language).
|
|
321
|
+
|
|
322
|
+
### Language
|
|
323
|
+
|
|
324
|
+
Every user-facing string the TUI draws is looked up in a catalog at paint time: the title screen, header, sidebar, transcript labels, tool rows, composer, status row and rail, settings chrome and values, help overlay, rewind picker, stats window, question and approval boxes, toasts, and the `/help` command descriptions. The language is one setting — **`Ctrl+P` → Main → Language** — with 17 choices plus `System`, labelled in their own script (`简体中文`, `日本語`, `Português (Brasil)`, `العربية`, …). Choosing one repaints immediately; nothing restarts. The choice is stored wherever the running dsh keeps settings — the profile's `cordis.patch.yml` as the `tui-app` row's `locale` field on dsh 0.2.0, or `$DSH_HOME/settings.yaml` as `tui-locale.preference` on 0.1.x — and `System` stores nothing at all, so a later change of OS language is followed.
|
|
325
|
+
|
|
326
|
+
With `System` selected (the default) the boot resolves the language in this order, first hit wins:
|
|
327
|
+
|
|
328
|
+
1. `DSH_TUI_LOCALE`
|
|
329
|
+
2. `LANGUAGE`
|
|
330
|
+
3. `LC_ALL` / `LC_MESSAGES` / `LANG`
|
|
331
|
+
4. the OS locale (on Windows, the user's display language)
|
|
332
|
+
5. `en`
|
|
333
|
+
|
|
334
|
+
Values may be POSIX style (`de_DE.UTF-8`), BCP 47 (`pt-BR`), or a bare language (`zh`, `fr`); matching is case-insensitive, and an alias resolves to the nearest catalog it actually has (`zh`/`zh-Hans` → `zh-CN`, `zh-Hant`/`zh-HK` → `zh-TW`, `pt`/`pt-PT` → `pt-BR`, `in` → `id`). A language with no catalog — Norwegian, say — falls back to English rather than to a neighbour it does not resemble. `DSH_TUI_LOCALE=en` also pins the `--help` text, which is printed before the settings file is read.
|
|
335
|
+
|
|
336
|
+
English is the source catalog: it defines the key set, every other locale must cover all of it (a test enforces parity in both directions), and any key a locale is missing falls back to English rather than showing a raw key. Plural families go through `Intl.PluralRules`, so Russian and Arabic get their full CLDR forms while English and Chinese use the two or one they have. Product and protocol tokens (`DeepSeek Harness`, `dsh`, `MCP`, `stdio`, `Ctrl+P`, `Tab`, `Esc`, `Enter`, `git`, `npm`, …) stay literal in every locale.
|
|
337
|
+
|
|
338
|
+
Adding a language is one file: `lib/locales/<id>.js` exporting a flat `{ 'area.key': 'text' }` object, plus an entry in `LOCALES` in `lib/i18n.js`. `npm test` then checks its key coverage, placeholder parity, and plural forms against English.
|
|
339
|
+
|
|
340
|
+
### MCP servers
|
|
341
|
+
|
|
342
|
+
`Ctrl+P → MCP` is the TUI's own MCP page. Servers are mounted on the **current session's agent** through the host's MCP client, so the page shows live connection state once a session is open (on the title screen the configuration is still listed, just not connected).
|
|
343
|
+
|
|
344
|
+
`Enter` on a server edits it, `Add MCP server` starts a new one, and `Delete` (or `Backspace`) on a server row asks y/n before removing the configuration and disconnecting. The form holds:
|
|
345
|
+
|
|
346
|
+
| Field | Meaning |
|
|
347
|
+
| --- | --- |
|
|
348
|
+
| Name | The tool namespace: 1–32 letters, digits, `-` or `_`. Fixed while editing, because renaming a server is removing one and adding another. |
|
|
349
|
+
| Type | `Local command` (a child process over stdio), `HTTP` (Streamable HTTP), or `SSE` (the legacy HTTP+SSE transport). |
|
|
350
|
+
| Timeout (ms) | Per tool call or resource request; defaults to `30000`. |
|
|
351
|
+
| Command / Arguments / Environment (JSON) | For a local command. Arguments split on whitespace and group with double quotes, or you can paste a JSON array. The environment is a JSON object whose values may be strings, numbers, or booleans. |
|
|
352
|
+
| URL / Headers (JSON) | For `HTTP` and `SSE`. |
|
|
353
|
+
| Scope | `User` (`$DSH_HOME/mcp.json`, every session) or `Path` (a `<path>/.dsh/mcp.json`, sessions whose working directory is inside `<path>`), followed by the `Path` row itself. |
|
|
354
|
+
| Enabled | `off` keeps the configuration but does not connect. |
|
|
355
|
+
|
|
356
|
+
`Save` writes the record and mounts it right away; a local command inherits the session's working directory.
|
|
357
|
+
|
|
358
|
+
State rows read `● connected` (no readable tool list), `● N tools`, `… connecting`, `⚠ <reason>`, `○ disabled`, or `○ not connected`, and the tools one server registered are listed indented underneath it. Below the list, the `Config files` rows name every file that was read and whether it exists — the answer to "why is this server here".
|
|
359
|
+
|
|
360
|
+
**Scope is a property of the file, not a field of the record.** The effective list for a session is the user file plus every `<dir>/.dsh/mcp.json` between the session's working directory and the filesystem root; a nearer file wins a duplicate name. Editing a server and switching its scope moves the record to the other file. Servers are plain JSON:
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{
|
|
364
|
+
"version": 1,
|
|
365
|
+
"servers": {
|
|
366
|
+
"github": {
|
|
367
|
+
"type": "stdio",
|
|
368
|
+
"command": "npx",
|
|
369
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
370
|
+
"env": { "GITHUB_TOKEN": "…" },
|
|
371
|
+
"timeoutMs": 30000,
|
|
372
|
+
"enabled": true
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The TUI re-reads those files while the MCP page is open and remounts what changed, so a server edited in another window — or imported by the model through the bundled **`mcp-import` skill** — appears and connects without restarting the session. The skill ships with the plugin and teaches the model how to add, fix, disable, or remove a server for you.
|
|
379
|
+
|
|
380
|
+
Environment values and headers are **plain text** in those files, and the page says so on its `Secrets` row: there is no credential vault here and no OAuth.
|
|
381
|
+
|
|
382
|
+
**MCP is optional by design.** No server configured, a server that refuses to connect, a host whose MCP client is missing, or a config file that will not parse all cost exactly one `⚠` row — sessions, input, and every other feature keep working.
|
|
383
|
+
|
|
384
|
+
**SSE.** The host's MCP client supports stdio and Streamable HTTP only, so the `SSE` type runs `bin/mcp-sse-bridge.mjs` — a zero-dependency stdio↔legacy-SSE proxy shipped with the plugin — as the child process. The host's tool bridge, reconnect policy, and per-call timeout then apply exactly as they do to a local command.
|
|
279
385
|
|
|
280
386
|
### In-app updates
|
|
281
387
|
|
|
@@ -286,18 +392,20 @@ Only providers you actually added (present in your user settings layer) are list
|
|
|
286
392
|
- `No stable release — pick from Versions` — the registry has no stable release yet; pick one manually.
|
|
287
393
|
- `Install damaged — reinstall below` — the global dsh tree is in a mixed old/new state; reinstall it.
|
|
288
394
|
|
|
289
|
-
`Enter` on a package's **Versions** row opens the full registry list (newest first, `[latest]`/`[next]`/other tags and `(installed)` colour-coded) where you can pick any version — including pre-releases
|
|
395
|
+
`Enter` on a package's **Versions** row opens the full registry list (newest first, `[latest]`/`[next]`/other tags and `(installed)` colour-coded) where you can pick any version — including pre-releases. A picked version is **downloaded first**: the tarball streams into `$DSH_HOME/updates` with live byte progress, `Esc` cancels mid-flight, and the sha512 registry hash is checked before the file is kept. Applying it is a **restart**: after the y/n confirm the TUI spawns a detached applier, exits, and that child installs from the staged tarball, verifies the on-disk version, and relaunches dsh in the same terminal with the same arguments (`--profile tui --resume …` included) and working directory.
|
|
396
|
+
|
|
397
|
+
Version rows are tagged `[cached]` when that tarball is already on disk — a version you downloaded before (or rolled back from) needs no new download — and `[staged]` for the version waiting to be applied. A staged install also shows up on the Update tab as **Restart to apply x.y.z** (one `Enter`) and **Discard downloaded x.y.z**, and its state survives a crash: the row is only offered while the staged tarball actually exists. `Check now` re-reads the registry; `Startup check` toggles the silent boot-time stable-release check.
|
|
290
398
|
|
|
291
399
|
<details>
|
|
292
|
-
<summary><strong>
|
|
400
|
+
<summary><strong>Why an update never installs while the TUI runs</strong></summary>
|
|
293
401
|
|
|
294
|
-
|
|
402
|
+
Updating a package that a live `dsh` process has loaded can *silently corrupt* it: on Windows the process holds memory-mapped native DLLs, npm's directory replace fails, and the result is an old/new hybrid tree — while npm still exits 0. The crash of 2026-09-04 happened exactly that way. Migrating the whole flow to "download, then restart to apply" removed the window instead of narrowing it, on every platform:
|
|
295
403
|
|
|
296
|
-
1. **
|
|
297
|
-
2. **The on-disk version
|
|
298
|
-
3. **
|
|
404
|
+
1. **Nothing is installed until this process is gone.** The download is only a file; the applier polls this pid, and the TUI refuses to start the restart while any *other* dsh process is alive.
|
|
405
|
+
2. **The on-disk version decides success**, not npm's exit code. A mismatch is reported as `Install damaged — reinstall below` with repair instructions, never as a bogus success.
|
|
406
|
+
3. **The staged tarball is kept**, so a rollback to any downloaded version is a verified cache hit through the same code path as an update.
|
|
299
407
|
|
|
300
|
-
|
|
408
|
+
The applier writes the outcome to `$DSH_HOME/tui-dsh-install.json`, which the Update page reads and drains on the next visit — so the result of an interrupted install is still reported.
|
|
301
409
|
</details>
|
|
302
410
|
|
|
303
411
|
## How it works
|
|
@@ -312,6 +420,24 @@ macOS/Linux have no DLL lock, but an install is refused while other dsh processe
|
|
|
312
420
|
- Agents are created and resumed through `ctx.agents`, the transcript is rebuilt from the session's durable log and fed live by `session/event` (including `assistant/chunk`), model defaults come from `ctx.agentDefaultModel`, and approvals answer the `approval/request` waterfall inline.
|
|
313
421
|
- `ask_user_question` is answered over the `user-questions/request` waterfall: a scoped Cordis waterfall where the modal either returns an answer or delegates with `next()`. An aborted request rejects so the service reports its own `ASK_ABORTED`; requests addressed to another agent are delegated untouched.
|
|
314
422
|
|
|
423
|
+
## Graphics rendering
|
|
424
|
+
|
|
425
|
+
Images and mermaid diagrams render as real pixels. On boot the TUI probes the terminal itself — DA1 attributes, an XTVERSION name query, a kitty graphics query, and a cell-size query — and every probe is an escape sequence the *client* terminal answers, so detection works unchanged over SSH where environment variables do not propagate.
|
|
426
|
+
|
|
427
|
+
| Terminal | Protocol used |
|
|
428
|
+
| --- | --- |
|
|
429
|
+
| kitty, Ghostty | kitty graphics (transmitted once per image, moved by reference — free on scroll) |
|
|
430
|
+
| WezTerm | kitty graphics, or iTerm2 inline images |
|
|
431
|
+
| iTerm2, Konsole, mintty | iTerm2 inline images / sixel |
|
|
432
|
+
| Windows Terminal ≥ 1.22 | sixel |
|
|
433
|
+
| GNOME Terminal, older xterm, anything else | truecolor half-block preview (▀, every font has the glyphs) |
|
|
434
|
+
|
|
435
|
+
- **Sources.** A pasted image (bytes are already in hand), a stored attachment on resume/replay (read back through the attachment store), a `data:` URL, and a standalone markdown image (``), plus tool-result images such as screenshots.
|
|
436
|
+
- **Budgets.** Images scale to fit the transcript width and a row cap (Settings → Graphics → Image height cap, default 20 rows); GIFs render their first frame.
|
|
437
|
+
- **Mermaid.** A ` ```mermaid ` fence draws as **native box-drawing art** whenever the grammar is one the built-in engine knows (flowchart, sequence, class, state, ER, pie, mindmap, timeline, gitGraph) — text in the transcript, sharp at any font size, selectable and searchable, with emoji and CJK labels measured the way the terminal draws them. Only a grammar the engine does not draw falls back to the provider chain: local `mmdc` (mermaid-cli) if it is on `PATH`, otherwise the mermaid.ink service (10 s timeout, disk-cached) — which sends the diagram text to a third party, hence the setting. A diagram wider than the pane reports the width it needs and shows its source instead of a raster. `Settings → Graphics → Mermaid rendering` offers `auto` / `local` / `off`; `off` keeps every diagram as its highlighted source, and every failure degrades to source, never a broken frame.
|
|
438
|
+
- **Settings.** `Graphics rendering` (`auto`/`off`), `No-protocol fallback` (`halfblock`/`chip`), `Image height cap (rows)`, `Mermaid rendering` (`auto`/`local`/`off`). Changes apply the moment they are saved.
|
|
439
|
+
- **Dependencies.** Rendering is pure npm: `sharp` decodes and scales (prebuilt binaries on all three platforms), [`lovely-mermaid`](https://www.npmjs.com/package/lovely-mermaid) lays out mermaid diagrams as terminal text, and the kitty/iTerm2/sixel encoders are part of the plugin. No external binaries are required; `mmdc` is optional.
|
|
440
|
+
|
|
315
441
|
## @dsh-std interop
|
|
316
442
|
|
|
317
443
|
The package carries a [`dsh-plugin.json`](dsh-plugin.json) manifest and a facet at `lib/facet.js`, so a `@dsh-std` host can discover it without running any code. **What ships today is that static manifest and the preflight surface; the runtime protocol support is currently gated off by an upstream limitation.** The plugin is designed as a **host and Presentation provider** in that ecosystem, not a protocol consumer — its facet is wired to publish `presentation.dsh/v1alpha1` `UserInteraction` / `Notification` / `CopyText` and `commands.dsh/v1alpha1` `CommandRuntime` for other components to drive. Its one `requires.contracts` entry is the `commands.dsh/v1alpha1` `Command` resource, declared **`optional: true`**. The entry is retained for a future consumer and is inert today — nothing calls `protocols.client(...)`, and its own command line is parsed from the cordis `ctx.commands` service, so the TUI behaves identically whether or not a Command provider exists. An unsatisfiable non-optional requirement would be a hard activation failure rather than a warning, and the `optional` flag is what keeps the declaration honest about that. But a Community v0.15 manifest **cannot declare protocol supports**, and `@dsh-std/lifecycle`'s `implement` — which *is* `stageProtocol` — refuses to stage a support the facet has not declared, so the facet stages **nothing**. The protocol shims are written and test-covered; they go live unchanged once upstream closes the gap. See [docs/dsh-std-接入说明.md](docs/dsh-std-接入说明.md#阻断性发现本插件当前不发布任何协议-support) for the layer-by-layer evidence.
|
|
@@ -322,6 +448,9 @@ The package carries a [`dsh-plugin.json`](dsh-plugin.json) manifest and a facet
|
|
|
322
448
|
|
|
323
449
|
## Development
|
|
324
450
|
|
|
451
|
+
AI agents working in this checkout should read [AGENTS.md](AGENTS.md) first — it
|
|
452
|
+
records the build/install loop as a rule rather than a suggestion.
|
|
453
|
+
|
|
325
454
|
```sh
|
|
326
455
|
npm run check # node --check over lib/, bin/
|
|
327
456
|
npm test # standalone smoke tests (no dsh needed)
|
|
@@ -333,11 +462,17 @@ npm test # standalone smoke tests (no dsh needed)
|
|
|
333
462
|
npm pack # -> dsh-oc-tui-<version>.tgz
|
|
334
463
|
dsh plugin --profile tui remove -w dsh-oc-tui # detach the old copy FIRST
|
|
335
464
|
Remove-Item .\*.tgz # then drop the stale tarball
|
|
336
|
-
npm pack
|
|
465
|
+
npm pack # repack the current tree
|
|
466
|
+
Copy-Item .\dsh-oc-tui-<version>.tgz $env:USERPROFILE\.dsh\profiles\tui\
|
|
337
467
|
dsh plugin --profile tui add -w .\dsh-oc-tui-<version>.tgz
|
|
468
|
+
Remove-Item $env:USERPROFILE\.dsh\profiles\tui\dsh-oc-tui-<version>.tgz
|
|
338
469
|
```
|
|
339
470
|
|
|
340
|
-
|
|
471
|
+
Three things that will bite you:
|
|
472
|
+
|
|
473
|
+
- **Detach before deleting.** pnpm resolves the profile's existing `file:` dependency while adding, so a dependency pointing at a deleted tarball aborts the whole install with `ENOENT`.
|
|
474
|
+
- **The tarball has to be reachable from the profile directory while you add it.** dsh hands package specs to pnpm with `cwd` pinned to the profile directory, and pnpm 9.15.9 then looks for the tarball *by name inside the profile* rather than at the absolute path it was given: the `add` fails with `ENOENT: no such file or directory, open '…\.dsh\profiles\tui\dsh-oc-tui-<version>.tgz'`. Copy it in first; dsh writes the absolute path into the profile manifest, so the copy comes straight back out afterwards.
|
|
475
|
+
- **Remove before adding, even at the same version.** dsh appends a bundle to `dsh.profile.bundles` only for dependencies that were *not* already installed, so adding over an existing copy installs the files and silently skips the bundle row — the profile then boots dsh-base with no TUI.
|
|
341
476
|
|
|
342
477
|
Verify the swap actually landed — the version string proves nothing:
|
|
343
478
|
|
|
@@ -351,6 +486,12 @@ foreach ($rel in @('lib\index.js','lib\ui.js','lib\util.js','lib\term.js','lib\m
|
|
|
351
486
|
}
|
|
352
487
|
```
|
|
353
488
|
|
|
489
|
+
The reverse failure is just as silent and worse: current files with no `dsh-oc-tui` row in `dsh.profile.bundles` means dsh-base boots with no TUI. Confirm the layer composes:
|
|
490
|
+
|
|
491
|
+
```sh
|
|
492
|
+
dsh --profile tui --dump-config | grep -A2 tui-app
|
|
493
|
+
```
|
|
494
|
+
|
|
354
495
|
Then boot it for real. Reaching the title screen is not enough — the session-open path is where host API breaks surface, so send a message. Test `--resume` separately, because it is an apply-time path that can lose a startup race the post-boot paths win.
|
|
355
496
|
|
|
356
497
|
For a zero-install bootstrap that skips packaging entirely, create the profile once and point its patch at this checkout:
|
|
@@ -391,36 +532,44 @@ More detail, in Chinese: [docs/用户手册.md](docs/用户手册.md).
|
|
|
391
532
|
|
|
392
533
|
## Known limitations
|
|
393
534
|
|
|
394
|
-
- IME composition is not exposed by the zero-dependency terminal engine yet. Pasted images are: a bracketed paste of raw image bytes, a `data:image/...;base64,...` URL, a local image path, or an image URL
|
|
535
|
+
- IME composition is not exposed by the zero-dependency terminal engine yet. Pasted images are: a bracketed paste of raw image bytes, a `data:image/...;base64,...` URL, a local image path, or an image URL — each becomes an attachment and renders in the transcript (see [Graphics rendering](#graphics-rendering)); pasting text nothing recognizes asks the terminal for its clipboard (OSC 52).
|
|
395
536
|
- The plugin does not hot-reload: the profile's HMR root is the profile directory, so a running TUI keeps the copy it booted with.
|
|
396
537
|
- `dsh tui` as a bare subcommand needs a shell alias — the stock launcher hardcodes only `web` and `plugin`.
|
|
397
|
-
- Harness slash commands need a live session; on the title screen the TUI tells you to start
|
|
538
|
+
- Harness slash commands need a live session; on the title screen the TUI tells you to start a session first.
|
|
398
539
|
- Deferring a question with `Esc` does not cancel the tool call — it delegates, and with no other answerer the tool call fails. Per-question skip (as the Web UI composer offers) is not implemented.
|
|
399
540
|
- `--resume`, Settings → Manage sessions, the context meter, and the stats strip depend on services mounted by `@deepseek-ai/dsh-base` (`sessionQuery`, `sessionProjections`); a hand-built profile must provide them. The `sessionStats` projection is a web-app-layer row, so the TUI folds those figures from the session log itself when no profile mounts it.
|
|
400
|
-
-
|
|
541
|
+
- An update restart only waits for the TUI that scheduled it: the "no other dsh process" check runs *before* you confirm, so a `dsh web` window opened afterwards can still overlap the install. Close other dsh windows first.
|
|
401
542
|
- Two `@dsh-std` presentation surfaces have no caller today, because the runtime protocol support is gated off (see [@dsh-std interop](#dsh-std-interop)). A standard `secret-input` request would open a masked prompt with `minLength`/`maxLength` bounds, and a standard `ApprovalRequest` is the only thing that populates the approval box's `Origin` / `Risk` / `Details` rows. Both paths are written and integration-tested; neither can be reached from a normal `dsh --profile tui` session, which is why the harness-shaped approval box shows only the action and the summary.
|
|
543
|
+
- Graphics: an image whose top rows are scrolled out of the transcript window shows as blank placeholder rows until it fits fully again (kitty placements cannot anchor off-screen); GIFs animate their first frame only; sixel/iTerm2 payloads are re-emitted when an image's row moves, which is fine locally and throttled on slow SSH links.
|
|
402
544
|
|
|
403
545
|
## Layout
|
|
404
546
|
|
|
405
547
|
```
|
|
406
548
|
lib/index.js plugin entry: agents, events, input, commands, approvals, user questions
|
|
407
549
|
lib/startup.js command-line provider (tuiStartup service)
|
|
408
|
-
lib/term.js terminal engine (raw mode, screen, key decoding)
|
|
409
|
-
lib/
|
|
550
|
+
lib/term.js terminal engine (raw mode, screen, key decoding, image slab emission)
|
|
551
|
+
lib/caps.js terminal capability probing (DA1, XTVERSION, kitty graphics, cell size)
|
|
552
|
+
lib/image.js image pipeline (decode/scale + halfblock, sixel, kitty, iTerm2 encoders)
|
|
553
|
+
lib/mermaid.js mermaid provider chain (mmdc → mermaid.ink → highlighted fallback), tried only for grammars the built-in engine cannot draw
|
|
554
|
+
lib/mermaid-ascii.js native terminal art for mermaid fences (box-drawing layout, tried before the image path)
|
|
555
|
+
lib/ui.js responsive view model + renderer (flat transcript, tool rows, question modal)
|
|
556
|
+
lib/splash-opencode.js title-screen art (OpenCode-style layout, DeepSeek gradient mark)
|
|
410
557
|
lib/metrics.js whole-session stats + token usage fold (web stats strip / tokenUsage port)
|
|
558
|
+
lib/rewind.js rewind picker: conversation fork from the event log plus fenced file restore
|
|
411
559
|
lib/interrupt.js Ctrl+C lifecycle state
|
|
412
|
-
lib/web-settings.js shared WebUI settings projection
|
|
413
|
-
lib/updates.js in-app update manager (npm registry +
|
|
414
|
-
lib/markdown.js markdown -> styled lines
|
|
560
|
+
lib/web-settings.js shared WebUI settings projection (incl. the tui-graphics namespace)
|
|
561
|
+
lib/updates.js in-app update manager (npm registry, download + staging, install verification)
|
|
562
|
+
lib/markdown.js markdown-it token stream -> styled lines (tables, hljs, links)
|
|
415
563
|
lib/util.js text/display helpers
|
|
416
564
|
lib/bridge.js live-TUI registry the @dsh-std facet forwards through
|
|
417
565
|
lib/facet.js @dsh-std facet entry (interop shell; never starts the TUI)
|
|
418
566
|
lib/std/ @dsh-std protocol adapters (adapt, presentation, commands, command-list)
|
|
419
567
|
bin/dsh-oc-tui.js convenience launcher for `dsh --profile tui`
|
|
568
|
+
bin/apply-update.cjs detached applier: waits for the TUI to exit, installs the staged tarball, relaunches dsh
|
|
420
569
|
install.sh one-command installer (Linux/macOS)
|
|
421
570
|
install.ps1 one-command installer (Windows)
|
|
422
571
|
dsh-plugin.json @dsh-std component manifest (discovery + preflight only)
|
|
423
|
-
cordis.patch.yml bundle patch layer (TUI rows, agent-
|
|
572
|
+
cordis.patch.yml bundle patch layer (TUI rows, ask-user tool; no agent-preset row, with the reason in a comment)
|
|
424
573
|
docs/用户手册.md Chinese user manual
|
|
425
574
|
docs/dsh-std-接入说明.md @dsh-std interop scope and constraints (Chinese)
|
|
426
575
|
tests/smoke.test.mjs standalone smoke tests
|
|
@@ -429,3 +578,5 @@ tests/smoke.test.mjs standalone smoke tests
|
|
|
429
578
|
## License
|
|
430
579
|
|
|
431
580
|
[LGPL-3.0-or-later](LICENSE).
|
|
581
|
+
|
|
582
|
+
**Third-party.** [`lovely-mermaid`](https://www.npmjs.com/package/lovely-mermaid) 0.3.3 (Apache-2.0, © Alexey Zaytsev / ccch1mneyyy) lays out mermaid diagrams as terminal text; its license ships in its package, and its source is at [github.com/ccch1mneyyy/lovely-mermaid](https://github.com/ccch1mneyyy/lovely-mermaid).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Detached applier for a TUI-driven update. Spawned by the running TUI
|
|
3
|
+
// immediately before it exits: `node bin/apply-update.cjs <plan.json>`.
|
|
4
|
+
//
|
|
5
|
+
// The plan is a JSON file (written by the TUI into $DSH_HOME/updates) naming
|
|
6
|
+
// one package (dsh or dsh-oc-tui), the staged tarball to install from, how to
|
|
7
|
+
// verify the result, and how to relaunch. The child waits for the TUI to die
|
|
8
|
+
// *first* — that is the whole point of the restart handoff: a global npm
|
|
9
|
+
// install (or a profile reinstall) while a dsh process is alive can leave a
|
|
10
|
+
// hybrid old/new tree behind, and npm still exits 0.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately self-contained: it outlives the plugin that spawned it,
|
|
13
|
+
// including when that plugin is the package being replaced, so it imports
|
|
14
|
+
// nothing from the plugin and stays plain CJS.
|
|
15
|
+
'use strict'
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
const cp = require('node:child_process')
|
|
20
|
+
const { createRequire } = require('node:module')
|
|
21
|
+
|
|
22
|
+
const PARENT_WAIT_MS = 600_000
|
|
23
|
+
|
|
24
|
+
function log(line) {
|
|
25
|
+
try { process.stdout.write('[dsh-oc-tui] ' + line + '\n') } catch { /* terminal already gone */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function alive(pid) {
|
|
29
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function installedVersion(manifestPath) {
|
|
33
|
+
try {
|
|
34
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
35
|
+
return typeof manifest.version === 'string' ? manifest.version : null
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Re-resolve the dsh bin entry through the shim *after* the install, so a
|
|
42
|
+
// layout change inside a new dsh version cannot break the relaunch.
|
|
43
|
+
function entryFromShim(shim) {
|
|
44
|
+
if (typeof shim !== 'string' || shim === '') return null
|
|
45
|
+
try {
|
|
46
|
+
const manifestPath = createRequire(path.resolve(shim)).resolve('@deepseek-ai/dsh/package.json')
|
|
47
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
48
|
+
const entry = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin && manifest.bin.dsh
|
|
49
|
+
return typeof entry === 'string' && entry !== '' ? path.resolve(path.dirname(manifestPath), entry) : null
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function relaunchTarget(plan) {
|
|
56
|
+
const entry = entryFromShim(plan.dshShim)
|
|
57
|
+
if (entry) return { command: process.execPath, args: [entry, ...(plan.dshArgs ?? [])] }
|
|
58
|
+
const fallback = plan.relaunch
|
|
59
|
+
if (fallback && Array.isArray(fallback.args) && fallback.args.length > 0) return { command: fallback.command ?? process.execPath, args: fallback.args }
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeMarker(plan, code) {
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(path.dirname(plan.marker), { recursive: true })
|
|
66
|
+
fs.writeFileSync(plan.marker, JSON.stringify({
|
|
67
|
+
requested: plan.version,
|
|
68
|
+
pkg: plan.pkg === 'tui' ? 'tui' : 'dsh',
|
|
69
|
+
code,
|
|
70
|
+
finishedAt: Date.now(),
|
|
71
|
+
}))
|
|
72
|
+
} catch {
|
|
73
|
+
// Unwritable home: the next boot simply has nothing to verify.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The tarball itself stays in the cache — a rollback to the same version then
|
|
78
|
+
// costs no download. Only the "waiting to be applied" record goes.
|
|
79
|
+
function dropStaged(plan) {
|
|
80
|
+
try { fs.unlinkSync(path.join(path.dirname(plan.tarball), 'staged.json')) } catch { /* nothing staged */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runInstall(plan) {
|
|
84
|
+
if (plan.pkg === 'dsh') {
|
|
85
|
+
if (!plan.npm) {
|
|
86
|
+
log('npm could not be located — install manually: npm install -g @deepseek-ai/dsh@' + plan.version)
|
|
87
|
+
return { code: 1 }
|
|
88
|
+
}
|
|
89
|
+
const result = cp.spawnSync(plan.npm.command, [...plan.npm.args, 'install', '-g', plan.tarball], {
|
|
90
|
+
stdio: 'inherit',
|
|
91
|
+
windowsHide: false,
|
|
92
|
+
})
|
|
93
|
+
return { code: result.status === null ? 1 : result.status }
|
|
94
|
+
}
|
|
95
|
+
if (!plan.dsh) {
|
|
96
|
+
log('the dsh CLI could not be located — reinstall the plugin with: dsh plugin --profile ' + plan.profile + ' add -w ' + plan.tarball)
|
|
97
|
+
return { code: 1 }
|
|
98
|
+
}
|
|
99
|
+
// Reinstalling the same version does not reliably replace the installed
|
|
100
|
+
// copy, so detach the dependency first — the documented dev-loop order.
|
|
101
|
+
const detach = cp.spawnSync(plan.dsh.command, [
|
|
102
|
+
...plan.dsh.args, 'plugin', '--profile', plan.profile, 'remove', '-w', plan.packageName,
|
|
103
|
+
], { stdio: 'inherit', windowsHide: false })
|
|
104
|
+
if (detach.status !== 0) log('detaching the previous copy exited ' + detach.status + ' — continuing anyway')
|
|
105
|
+
const add = cp.spawnSync(plan.dsh.command, [
|
|
106
|
+
...plan.dsh.args, 'plugin', '--profile', plan.profile, 'add', '-w', plan.tarball,
|
|
107
|
+
], { stdio: 'inherit', windowsHide: false })
|
|
108
|
+
return { code: add.status === null ? 1 : add.status }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function apply(plan) {
|
|
112
|
+
log('installing ' + plan.packageName + '@' + plan.version + ' from ' + plan.tarball)
|
|
113
|
+
const result = runInstall(plan)
|
|
114
|
+
const found = plan.verifyPath ? installedVersion(plan.verifyPath) : null
|
|
115
|
+
if (result.code === 0) {
|
|
116
|
+
if (found === plan.version) {
|
|
117
|
+
log('verified ' + plan.packageName + '@' + plan.version)
|
|
118
|
+
} else {
|
|
119
|
+
// npm exit 0 is not proof — the on-disk manifest decides. The marker
|
|
120
|
+
// (code 0, mismatched version) is what the next boot reads back as a
|
|
121
|
+
// damaged install.
|
|
122
|
+
log('WARNING: the install reported success but ' + (plan.verifyPath ?? 'the manifest') +
|
|
123
|
+
' reports ' + (found ?? 'nothing') + ' (expected ' + plan.version + ')')
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
log('install failed (exit ' + result.code + ')')
|
|
127
|
+
}
|
|
128
|
+
writeMarker(plan, result.code)
|
|
129
|
+
dropStaged(plan)
|
|
130
|
+
relaunch(plan)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function relaunch(plan) {
|
|
134
|
+
const target = relaunchTarget(plan)
|
|
135
|
+
if (!target) {
|
|
136
|
+
log('could not locate the dsh entry — start dsh again yourself')
|
|
137
|
+
process.exit(0)
|
|
138
|
+
}
|
|
139
|
+
log('restarting dsh…')
|
|
140
|
+
try {
|
|
141
|
+
const child = cp.spawn(target.command, target.args, {
|
|
142
|
+
cwd: plan.cwd || undefined,
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: 'inherit',
|
|
145
|
+
windowsHide: false,
|
|
146
|
+
})
|
|
147
|
+
child.unref()
|
|
148
|
+
} catch (error) {
|
|
149
|
+
log('relaunch failed: ' + (error?.message ?? String(error)) + ' — start dsh again yourself')
|
|
150
|
+
}
|
|
151
|
+
process.exit(0)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const planFile = process.argv[2] ?? ''
|
|
155
|
+
let plan = null
|
|
156
|
+
if (planFile !== '') {
|
|
157
|
+
try {
|
|
158
|
+
plan = JSON.parse(fs.readFileSync(planFile, 'utf8'))
|
|
159
|
+
} catch {
|
|
160
|
+
plan = null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!plan || typeof plan.tarball !== 'string' || plan.tarball === '' || typeof plan.version !== 'string' || plan.version === '') {
|
|
164
|
+
log('no usable plan at ' + (planFile || '(no path given)') + ' — nothing to do')
|
|
165
|
+
process.exit(1)
|
|
166
|
+
}
|
|
167
|
+
if (!plan.marker) {
|
|
168
|
+
log('plan carries no marker path — nothing to do')
|
|
169
|
+
process.exit(1)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
;(function waitForParent() {
|
|
173
|
+
const deadline = Date.now() + PARENT_WAIT_MS
|
|
174
|
+
if (alive(plan.parentPid) && Date.now() < deadline) {
|
|
175
|
+
// Still inside the window: keep polling. The TUI paints its own screen
|
|
176
|
+
// until it is gone, so nothing is logged before this point.
|
|
177
|
+
setTimeout(waitForParent, 250)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (alive(plan.parentPid)) {
|
|
181
|
+
// Never went away (or the pid was reused): never install over a live tree.
|
|
182
|
+
writeMarker(plan, 1)
|
|
183
|
+
log('timed out waiting for the TUI to exit — nothing was installed')
|
|
184
|
+
process.exit(0)
|
|
185
|
+
}
|
|
186
|
+
apply(plan)
|
|
187
|
+
})()
|