@aiwayds/dsh-tui-pi 2.32.1 → 2.34.0

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 CHANGED
@@ -42,7 +42,7 @@ npm i -g @aiwayds/dsh-tui-pi
42
42
  dsh-tui-pi # or the short form: dst
43
43
  ```
44
44
 
45
- The global install sets the machine up by itself, printing one progress line per step: it installs the `@deepseek-ai/dsh` host when missing (via npm; if npm itself is not on PATH, it prints what to install and stops), installs pnpm, and creates the `tui` profile with this plugin and its bundled companions. Whatever the install cannot finish (e.g. an offline machine) completes automatically on the first launch. After a later `npm i -g` upgrade of this package, the profile's mounted copy is moved to the new version on the next launch — a profile that is newer than the global install (dev-upgraded ahead) is left untouched.
45
+ The global install sets the machine up by itself, printing one progress line per step: it installs the `@deepseek-ai/dsh` host when missing (via npm, resolved as the newest of the `latest`/`next` dist-tags — the `latest` tag alone lags the rc line; if npm itself is not on PATH, it prints what to install and stops), installs pnpm, and creates the `tui` profile with this plugin and its bundled companions. Host compatibility window: **0.2.1-rc.2 through 0.2.1-rc.4** — an rc.2 host is kept as-is (the subagent companion degrades gracefully there); an older one is upgraded automatically. Whatever the install cannot finish (e.g. an offline machine) completes automatically on the first launch. After a later `npm i -g` upgrade of this package, the profile's mounted copy is moved to the new version on the next launch — a profile that is newer than the global install (dev-upgraded ahead) is left untouched.
46
46
 
47
47
  The manual steps do exactly what the onboarding runs, if you prefer them:
48
48
 
@@ -53,6 +53,8 @@ dsh plugin --profile tui add @aiwayds/dsh-tui-pi
53
53
  dsh --profile tui # launch
54
54
  ```
55
55
 
56
+ Never add `@deepseek-ai/*` packages to a profile by hand — the host provides them, and their npm `latest` tag is a 0.0.1 stub (a bare-name `pnpm add @deepseek-ai/dsh-llm` lands that stub next to the plugin and shadows the host's copy; the symptom is the loader's `does not provide an export named …` and the plugin not activating). The `dsh-tui-pi` launcher checks for exactly this before every boot and, when it finds a shadowing copy, stops with both paths, both versions and the removal commands instead of booting into the broken state.
57
+
56
58
  Legacy `session_projcache` records (written before dsh 0.1.2-alpha.4) are migrated automatically at profile load — idempotent, with per-file backups, never blocking startup; the `dsh-tui-pi` launcher runs the same preflight.
57
59
 
58
60
  Everything that used to need manual patching — the canvas background, the `@deepseek-ai` module closure, the compaction backend — now happens automatically. For a normal install, upgrading the global package upgrades the profile on the next launch (see above). `dev-upgrade` remains for dev machines that install the profile from a checked-out repo:
package/bin/boot.mjs CHANGED
@@ -5,7 +5,11 @@
5
5
  * missing any of the three pieces (dsh host, pnpm, mounted tui profile).
6
6
  *
7
7
  * Fast path — host present and profile current: no output, straight to the
8
- * TUI, exactly like the plain `dsh --profile tui` this replaces.
8
+ * TUI, exactly like the plain `dsh --profile tui` this replaces. One check
9
+ * runs first (bin/host-guard.mjs): the mounted plugin must resolve the host
10
+ * packages it imports to the running host's own copies — a stray copy inside
11
+ * the profile would otherwise die at loader time with an unactionable
12
+ * SyntaxError, so the guard stops with the paths and the removal commands.
9
13
  *
10
14
  * Version follow: after `npm i -g` upgrades this package, the profile still
11
15
  * holds the old copy; the next launch (or the package's postinstall) re-runs
@@ -16,10 +20,13 @@
16
20
  */
17
21
 
18
22
  import { spawnSync } from 'node:child_process'
23
+ import { checkHostPackages } from './host-guard.mjs'
19
24
  import {
20
25
  dshBinPath,
26
+ dshHome,
21
27
  profileBehind,
22
28
  profileMounted,
29
+ profilePluginDir,
23
30
  runOnboard,
24
31
  } from './onboard-core.mjs'
25
32
 
@@ -33,6 +40,13 @@ function boot(dsh) {
33
40
  const dsh = dshBinPath()
34
41
 
35
42
  if (dsh && profileMounted() && !profileBehind()) {
43
+ const guard = checkHostPackages({
44
+ pluginDir: profilePluginDir(),
45
+ dshBin: dsh,
46
+ dshHome: dshHome(),
47
+ log: (line) => console.error(line),
48
+ })
49
+ if (!guard.ok) process.exit(1)
36
50
  boot(dsh)
37
51
  }
38
52
 
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Fast-path guard for the launcher (bin/boot.mjs): the plugin mounted in the
3
+ * profile must resolve every `@deepseek-ai/*` package it imports to the same
4
+ * copy the running host ships.
5
+ *
6
+ * Why this exists: plugins live in `~/.dsh/profiles/<name>/node_modules` and
7
+ * resolve host packages by walking up to the host's shared closure tree
8
+ * (`~/.dsh/profiles/node_modules`, links into the global host install). Any
9
+ * stray `@deepseek-ai/*` copy sitting closer to the plugin — a bare-name
10
+ * `pnpm add @deepseek-ai/dsh-llm` is the classic — shadows the host's copy.
11
+ * The failure then surfaces as a raw loader SyntaxError ("does not provide
12
+ * an export named …") that hides both the version conflict and its fix.
13
+ *
14
+ * Official dsh packages keep their npm `latest` dist-tag at a 0.0.1 stub
15
+ * (as of 2026-10: `@deepseek-ai/dsh-llm@latest` = 0.0.1-rc.1), so the stub
16
+ * lacks APIs introduced in the 0.1.3+ line. This guard turns that state
17
+ * into a plain-language stop with the removal commands, before `dsh` boots.
18
+ *
19
+ * Contract:
20
+ * - healthy profile: no output, returns { ok: true } — the fast path
21
+ * stays silent;
22
+ * - a profile-resident copy older than the host's: fatal stop with the
23
+ * two paths, the versions, and the exact removal commands;
24
+ * - ambiguous states (dev `link:` mounts, copies newer than the host,
25
+ * an unverifiable host side): one stderr line, boot continues — the
26
+ * guard never blocks a working setup on a guess.
27
+ */
28
+
29
+ import { createRequire } from 'node:module'
30
+ import { existsSync, readFileSync, readlinkSync, realpathSync } from 'node:fs'
31
+ import { dirname, isAbsolute, join, sep } from 'node:path'
32
+ import { compareVersions } from './onboard-core.mjs'
33
+
34
+ // Every `@deepseek-ai/*` package the compiled plugin imports at runtime
35
+ // (type-only imports in src/ are erased by tsc and do not count). Kept in
36
+ // lockstep with lib/ by test/host-guard.test.mjs; a drifted list fails CI.
37
+ export const HOST_PACKAGES = [
38
+ '@deepseek-ai/dsh-agent',
39
+ '@deepseek-ai/dsh-attachment-local',
40
+ '@deepseek-ai/dsh-commands',
41
+ '@deepseek-ai/dsh-llm',
42
+ '@deepseek-ai/dsh-permission-presets',
43
+ '@deepseek-ai/dsh-session',
44
+ '@deepseek-ai/dsh-session-persistence',
45
+ '@deepseek-ai/dsh-session-projection-cache',
46
+ '@deepseek-ai/dsh-settings',
47
+ '@deepseek-ai/dsh-tools',
48
+ '@deepseek-ai/schemastery',
49
+ ]
50
+
51
+ const HOST_NAME = '@deepseek-ai/dsh'
52
+
53
+ // Oldest host line this suite supports (the baked-in floor of
54
+ // onboard-core.mjs minHostVersion). Anything older inside a profile is not a
55
+ // release of anything — it is the npm `latest` stub.
56
+ const ANCIENT_FLOOR = '0.2.1-rc.2'
57
+
58
+ const PKG = '@aiwayds/dsh-tui-pi'
59
+
60
+ function readPackageAt(pkgPath) {
61
+ try {
62
+ return JSON.parse(readFileSync(pkgPath, 'utf8'))
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ // The nearest package root around a resolved file: version of the copy the
69
+ // plugin would actually load.
70
+ function packageInfoFromFile(file) {
71
+ let dir = dirname(file)
72
+ for (let i = 0; i < 8; i += 1) {
73
+ const pkg = readPackageAt(join(dir, 'package.json'))
74
+ if (pkg?.name) return { dir, version: pkg.version ?? null }
75
+ const parent = dirname(dir)
76
+ if (parent === dir) return null
77
+ dir = parent
78
+ }
79
+ return null
80
+ }
81
+
82
+ // The global host install root, from the `dsh` bin the launcher is about to
83
+ // spawn — `<root>/node_modules` is where its closure copies live.
84
+ export function hostRootFromBin(dshBin) {
85
+ try {
86
+ let dir = dirname(realpathSync(dshBin))
87
+ for (let i = 0; i < 6; i += 1) {
88
+ const pkg = readPackageAt(join(dir, 'package.json'))
89
+ if (pkg?.name === HOST_NAME) return dir
90
+ const parent = dirname(dir)
91
+ if (parent === dir) return null
92
+ dir = parent
93
+ }
94
+ } catch {
95
+ /* not a file, or unreadable */
96
+ }
97
+ return null
98
+ }
99
+
100
+ // True when the mounted plugin is a symlink pointing outside the dsh home —
101
+ // the dev `link:` checkout layout, where the checkout's own closure copy is
102
+ // legitimate and a version delta is a warning, not a stop.
103
+ function isDevMount(pluginDir, dshHome) {
104
+ let target
105
+ try {
106
+ target = readlinkSync(pluginDir)
107
+ if (!target) return false
108
+ } catch {
109
+ return false
110
+ }
111
+ const resolved = isAbsolute(target) ? target : join(dirname(pluginDir), target)
112
+ try {
113
+ return !realpathSync(resolved).startsWith(realpathSync(dshHome))
114
+ } catch {
115
+ return true
116
+ }
117
+ }
118
+
119
+ function resolveFrom(pluginDir, name) {
120
+ try {
121
+ const req = createRequire(join(pluginDir, 'package.json'))
122
+ return req.resolve(name)
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ // Version the host intends plugins to see: the shared closure tree first
129
+ // (what walk-up resolution is supposed to land on), then the host's own
130
+ // node_modules. Null when neither can be read — the guard then fails open.
131
+ function intendedInfo(name, { sharedTree, hostRoot }) {
132
+ const candidates = []
133
+ if (sharedTree) candidates.push(join(sharedTree, name))
134
+ if (hostRoot) candidates.push(join(hostRoot, 'node_modules', name))
135
+ for (const dir of candidates) {
136
+ const pkg = readPackageAt(join(dir, 'package.json'))
137
+ if (pkg?.name) return { dir, version: pkg.version ?? null }
138
+ }
139
+ return null
140
+ }
141
+
142
+ // The install root a resolved copy lives in (`…/<root>/node_modules/<name>`),
143
+ // used to shape the removal commands.
144
+ function installRootOf(resolvedPath) {
145
+ const marker = `${sep}node_modules${sep}`
146
+ const idx = resolvedPath.lastIndexOf(marker)
147
+ return idx === -1 ? null : resolvedPath.slice(0, idx)
148
+ }
149
+
150
+ function realOr(path) {
151
+ try {
152
+ return realpathSync(path)
153
+ } catch {
154
+ return path
155
+ }
156
+ }
157
+
158
+ function profileRemedy(name, resolvedPath, dshHome) {
159
+ const root = installRootOf(resolvedPath)
160
+ if (!root || !dshHome || !realOr(root).startsWith(realOr(dshHome))) {
161
+ return `remove the stray copy at ${resolvedPath}, then re-mount:\n dsh plugin --profile tui add ${PKG}`
162
+ }
163
+ const deps = readPackageAt(join(root, 'package.json'))?.dependencies ?? {}
164
+ const rm = name in deps
165
+ ? `cd ${root} && pnpm remove ${name}`
166
+ : `rm -rf ${resolvedPath}`
167
+ return `${rm}\n dsh plugin --profile tui add ${PKG}`
168
+ }
169
+
170
+ /**
171
+ * Check the mounted plugin's view of the host closure. Returns { ok, fatal,
172
+ * warnings }; prints nothing itself — pass a log to emit the diagnostics.
173
+ * All inputs optional except pluginDir; anything unresolvable degrades to
174
+ * warn, so an exotic install layout never blocks a working boot.
175
+ */
176
+ export function checkHostPackages({ pluginDir, dshBin, dshHome, log = () => {} } = {}) {
177
+ const fatal = []
178
+ const warnings = []
179
+ if (!pluginDir || !existsSync(join(pluginDir, 'package.json'))) {
180
+ return { ok: true, fatal, warnings }
181
+ }
182
+ const home = dshHome ?? ''
183
+ const sharedTree = join(home, 'profiles', 'node_modules')
184
+ const hostRoot = dshBin ? hostRootFromBin(dshBin) : null
185
+ const dev = home ? isDevMount(pluginDir, home) : false
186
+
187
+ for (const name of HOST_PACKAGES) {
188
+ const resolvedFile = resolveFrom(pluginDir, name)
189
+ const resolved = resolvedFile ? packageInfoFromFile(resolvedFile) : null
190
+ const intended = intendedInfo(name, { sharedTree: home ? sharedTree : null, hostRoot })
191
+
192
+ if (!resolved) {
193
+ // The plugin statically imports every package in the list; nothing
194
+ // resolvable means the loader is about to die on it.
195
+ const finding = {
196
+ name,
197
+ kind: 'unresolved',
198
+ severity: dev ? 'warn' : 'fatal',
199
+ resolved: null,
200
+ intended,
201
+ }
202
+ ;(finding.severity === 'fatal' ? fatal : warnings).push(finding)
203
+ continue
204
+ }
205
+ if (!intended?.version || !resolved.version) {
206
+ warnings.push({ name, kind: 'unverified', severity: 'warn', resolved, intended })
207
+ continue
208
+ }
209
+ const cmp = compareVersions(resolved.version, intended.version)
210
+ if (cmp === 0) continue
211
+ if (cmp < 0) {
212
+ const finding = {
213
+ name,
214
+ kind: 'shadowed',
215
+ severity: dev ? 'warn' : 'fatal',
216
+ ancient: compareVersions(resolved.version, ANCIENT_FLOOR) < 0,
217
+ resolved,
218
+ intended,
219
+ }
220
+ ;(finding.severity === 'fatal' ? fatal : warnings).push(finding)
221
+ } else {
222
+ warnings.push({ name, kind: 'newer', severity: 'warn', resolved, intended })
223
+ }
224
+ }
225
+
226
+ for (const f of warnings) {
227
+ if (f.kind === 'newer') {
228
+ log(`dsh-tui-pi: note — ${f.name} resolves to ${f.resolved.version} (${f.resolved.dir}), newer than the host's ${f.intended?.version ?? '(unreadable)'}; booting anyway.`)
229
+ } else if (f.kind === 'unverified') {
230
+ log(`dsh-tui-pi: note — ${f.name} resolves to ${f.resolved.version ?? '(unknown version)'} (${f.resolved.dir}); the host's own copy could not be read to compare.`)
231
+ } else if (f.kind === 'unresolved') {
232
+ log(`dsh-tui-pi: note — ${f.name} is not resolvable from the dev checkout; if the TUI fails to load it, run \`pnpm install\` in the checkout.`)
233
+ } else {
234
+ log(`dsh-tui-pi: dev mount — ${f.name} resolves to ${f.resolved.version} (${f.resolved.dir}), older than the host's ${f.intended?.version}; if the TUI fails to load, refresh the checkout's closure (\`pnpm install\`).`)
235
+ }
236
+ }
237
+
238
+ if (fatal.length) {
239
+ log('dsh-tui-pi: cannot boot cleanly — the profile resolves a dsh host package to a copy that is not the running host\'s.')
240
+ for (const f of fatal) {
241
+ if (f.kind === 'unresolved') {
242
+ log(`\n ${f.name}\n not resolvable from the mounted plugin at all.`)
243
+ log(`\n Fix — re-mount the suite and boot once so the host links its closure:\n dsh plugin --profile tui add ${PKG}\n dsh-tui-pi`)
244
+ continue
245
+ }
246
+ log(`\n ${f.name}`)
247
+ log(` the plugin resolves: ${f.resolved.version ?? '(unknown)'}`)
248
+ log(` ← ${f.resolved.dir}`)
249
+ log(` the host ships: ${f.intended?.version ?? '(unreadable)'}`)
250
+ log(` ← ${f.intended ? realOr(f.intended.dir) : '(shared closure link missing)'}`)
251
+ if (f.ancient) {
252
+ log('\n Versions before 0.2.1-rc.2 are stubs, not releases: official dsh packages keep their npm `latest` dist-tag at a 0.0.1 placeholder, so installing by bare package name (`pnpm add @deepseek-ai/dsh-llm`) lands a stub in the profile and breaks every plugin import. Install with an explicit version instead (e.g. @deepseek-ai/dsh-llm@0.2.1-rc.5).')
253
+ } else {
254
+ log('\n A stale copy inside the profile shadows the host\'s own.')
255
+ }
256
+ log(`\n Fix — ${profileRemedy(f.name, f.resolved.dir, home)}`)
257
+ }
258
+ }
259
+
260
+ return { ok: fatal.length === 0, fatal, warnings }
261
+ }
@@ -46,16 +46,19 @@ export function dshHome() {
46
46
  return process.env.DSH_HOME || join(homedir(), '.dsh')
47
47
  }
48
48
 
49
+ // Where `dsh plugin add` mounts this package inside the tui profile.
50
+ export function profilePluginDir() {
51
+ return join(dshHome(), 'profiles', PROFILE, 'node_modules', PKG)
52
+ }
53
+
49
54
  // The profile counts as set up once this package is resolvable inside it.
50
55
  export function profileMounted() {
51
- return existsSync(join(dshHome(), 'profiles', PROFILE, 'node_modules', PKG, 'package.json'))
56
+ return existsSync(join(profilePluginDir(), 'package.json'))
52
57
  }
53
58
 
54
59
  export function profileVersion() {
55
60
  try {
56
- return JSON.parse(
57
- readFileSync(join(dshHome(), 'profiles', PROFILE, 'node_modules', PKG, 'package.json'), 'utf8'),
58
- ).version ?? null
61
+ return JSON.parse(readFileSync(join(profilePluginDir(), 'package.json'), 'utf8')).version ?? null
59
62
  } catch {
60
63
  return null
61
64
  }
@@ -155,8 +158,14 @@ export function resolveHostVersion() {
155
158
  }
156
159
  }
157
160
 
158
- // The oldest host this plugin can run on, read from the manifest's own
159
- // peerDependencies so the declaration and the check stay one decision.
161
+ // The oldest host this plugin supports. Compatibility window: 0.2.1-rc.2
162
+ // through 0.2.1-rc.4 (rc.2 lacks the subagents.start* APIs, where the
163
+ // subagent-registry companion degrades gracefully via its host-compat
164
+ // guard; rc.3/rc.4 have the full surface). Read from the manifest's own
165
+ // peerDependencies when declared, so declaration and check stay one
166
+ // decision — note the floor must NOT move into peerDependencies itself:
167
+ // npm ≥7 auto-installs peers on global installs and would pull the 834MB
168
+ // host at its stale `latest` tag.
160
169
  export function minHostVersion() {
161
170
  try {
162
171
  const peer = readSelfPackage().peerDependencies?.[HOST]
@@ -165,7 +174,7 @@ export function minHostVersion() {
165
174
  } catch {
166
175
  /* fall through to the baked-in floor */
167
176
  }
168
- return '0.2.1-rc.3'
177
+ return '0.2.1-rc.2'
169
178
  }
170
179
 
171
180
  // True when the mounted profile copy is OLDER than this package. Prerelease
package/lib/index.js CHANGED
@@ -115,7 +115,7 @@ const SKILL_RESOURCE_BASE = {
115
115
  };
116
116
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
117
117
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
118
- const SKILL_DESCRIPTION = 'dsh TUI 增强套件使用与配置指南。凡涉及 TUI 主题/面板/footer、界面语言、子代理并发与轮数限制、模型收藏与隐藏、会话保留与 /resume 过滤、ask_user 超时、preset 记忆,或要配置 dsh-tui 段时先读本指南:插件配置 entry `dsh-tui` 的 17 键(language/theme/panelHeight/maxAgents/maxRounds/maxRoundsGrace/disableSubagent/registeredOnly/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume/askUser)、DSH_TUI_* 环境变量、快速上手向导、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、语言、language、收藏模型、隐藏模型、保留策略、panelHeight、resume、preset。';
118
+ const SKILL_DESCRIPTION = 'dsh TUI 增强套件配置指南。凡涉及 TUI 主题/面板/footer、界面语言、子代理并发与轮数限制、模型收藏与隐藏、会话保留与 /resume 过滤、ask_user 超时、空闲放锁、preset 记忆,或要配置 dsh-tui 段时先读本指南:entry `dsh-tui` 的 18 键(language/theme/panelHeight/maxAgents/maxRounds/maxRoundsGrace/disableSubagent/registeredOnly/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume/askUser/idleReleaseSec)、DSH_TUI_* 环境变量、快速上手、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、language、收藏模型、隐藏模型、保留策略、resume、preset、idleReleaseSec。';
119
119
  const SKILL_CANDIDATE = {
120
120
  name: SKILL_PROVIDER_NAME,
121
121
  description: SKILL_DESCRIPTION,
@@ -823,8 +823,27 @@ export function apply(ctx, config) {
823
823
  emitNotice(t('tui.takeover.writeLock'));
824
824
  ui.requestRender();
825
825
  },
826
+ onReattached: async (idRaw) => {
827
+ // The idle-released handle won its lease back on the next prompt or
828
+ // command: rebind exactly like a manual /resume and rebuild the
829
+ // display so the turns other surfaces wrote while the lock was free
830
+ // appear.
831
+ btwController.cancelAll();
832
+ const resumed = await bridge.resume(SessionId(idRaw));
833
+ refreshPermissionPreset();
834
+ renderer.clear();
835
+ liveWidgets.clear();
836
+ const session = resumed.agent.session;
837
+ const adopted = 'adopted' in resumed && resumed.adopted === true;
838
+ // Raw-event replay (T14 note — see onRemotePromotable above).
839
+ bridge.replay(adopted ? session.snapshotEvents() : session.snapshotEvents().filter(event => event.seq < session.firstLiveSeq));
840
+ emitNotice(t('tui.idleRelease.reattached'));
841
+ ui.requestRender();
842
+ },
826
843
  };
827
- const bridge = new DshSessionBridge(ctx, bridgeCallbacksWithTakeover);
844
+ const bridge = new DshSessionBridge(ctx, bridgeCallbacksWithTakeover, {
845
+ idleReleaseSec: () => config.idleReleaseSec.get(),
846
+ });
828
847
  bridgeRef = bridge;
829
848
  // Preset memory's creation-time leg: seed the remembered selection so the
830
849
  // FIRST session creation (the lazy create on the first submitted prompt)