@deepseek-harness-tui/dsh-tui 0.10.2 → 0.11.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.
Files changed (277) hide show
  1. package/README.md +171 -124
  2. package/bin/dsh-tui.js +748 -22
  3. package/cordis.patch.yml +38 -10
  4. package/cordis.yml +2 -0
  5. package/lib/types/adapter/channel/session-projection.d.ts.map +1 -1
  6. package/lib/types/adapter/channel/session-projection.js +2 -0
  7. package/lib/types/adapter/channel/ui-policy.d.ts +4 -1
  8. package/lib/types/adapter/channel/ui-policy.d.ts.map +1 -1
  9. package/lib/types/adapter/channel/ui-policy.js +5 -1
  10. package/lib/types/adapter/kernel/host-facade.d.ts.map +1 -1
  11. package/lib/types/adapter/kernel/host-facade.js +1 -0
  12. package/lib/types/adapter/kernel/replay.d.ts.map +1 -1
  13. package/lib/types/adapter/kernel/replay.js +3 -0
  14. package/lib/types/adapter/kernel/runtime.d.ts.map +1 -1
  15. package/lib/types/adapter/kernel/runtime.js +2 -0
  16. package/lib/types/adapter/kernel/slices/workspace.d.ts.map +1 -1
  17. package/lib/types/adapter/kernel/slices/workspace.js +1 -0
  18. package/lib/types/adapter/ports/channel-ui.d.ts +22 -2
  19. package/lib/types/adapter/ports/channel-ui.d.ts.map +1 -1
  20. package/lib/types/adapter/ports/channel-view.d.ts +66 -0
  21. package/lib/types/adapter/ports/channel-view.d.ts.map +1 -1
  22. package/lib/types/adapter/ports/channel-workspace.d.ts +23 -0
  23. package/lib/types/adapter/ports/channel-workspace.d.ts.map +1 -1
  24. package/lib/types/adapter/ports/workspace.d.ts +2 -0
  25. package/lib/types/adapter/ports/workspace.d.ts.map +1 -1
  26. package/lib/types/adapter/upstream/workspace-driver.d.ts.map +1 -1
  27. package/lib/types/adapter/upstream/workspace-driver.js +10 -2
  28. package/lib/types/commands.d.ts.map +1 -1
  29. package/lib/types/commands.js +1 -0
  30. package/lib/types/components/ContextBarView.d.ts +15 -7
  31. package/lib/types/components/ContextBarView.d.ts.map +1 -1
  32. package/lib/types/components/ContextBarView.js +26 -22
  33. package/lib/types/components/ImagePreviewOverlay.d.ts.map +1 -1
  34. package/lib/types/components/ImagePreviewOverlay.js +11 -5
  35. package/lib/types/components/Markdown.d.ts +11 -1
  36. package/lib/types/components/Markdown.d.ts.map +1 -1
  37. package/lib/types/components/Markdown.js +20 -2
  38. package/lib/types/components/MermaidDiagram.d.ts +13 -0
  39. package/lib/types/components/MermaidDiagram.d.ts.map +1 -0
  40. package/lib/types/components/MermaidDiagram.js +61 -0
  41. package/lib/types/components/MessageList.d.ts +26 -1
  42. package/lib/types/components/MessageList.d.ts.map +1 -1
  43. package/lib/types/components/MessageList.js +72 -7
  44. package/lib/types/components/PageMargin.d.ts +1 -1
  45. package/lib/types/components/PromptInput.d.ts +37 -1
  46. package/lib/types/components/PromptInput.d.ts.map +1 -1
  47. package/lib/types/components/PromptInput.js +243 -28
  48. package/lib/types/components/ScrollbarGutter.d.ts +20 -5
  49. package/lib/types/components/ScrollbarGutter.d.ts.map +1 -1
  50. package/lib/types/components/ScrollbarGutter.js +88 -10
  51. package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
  52. package/lib/types/components/StreamingMarkdown.js +30 -28
  53. package/lib/types/components/Tooltip.d.ts.map +1 -1
  54. package/lib/types/components/Tooltip.js +45 -7
  55. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  56. package/lib/types/components/messages/UserPromptMessage.js +5 -7
  57. package/lib/types/components/messages/useImageInspection.d.ts.map +1 -1
  58. package/lib/types/components/messages/useImageInspection.js +19 -9
  59. package/lib/types/components/promptDraftCache.d.ts +102 -0
  60. package/lib/types/components/promptDraftCache.d.ts.map +1 -0
  61. package/lib/types/components/promptDraftCache.js +76 -0
  62. package/lib/types/components/questions/AskUserQuestionPanel.d.ts +17 -1
  63. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  64. package/lib/types/components/questions/AskUserQuestionPanel.js +40 -7
  65. package/lib/types/components/questions/QuestionMinimizedBar.d.ts +12 -0
  66. package/lib/types/components/questions/QuestionMinimizedBar.d.ts.map +1 -0
  67. package/lib/types/components/questions/QuestionMinimizedBar.js +43 -0
  68. package/lib/types/components/sessions/SessionListRow.d.ts +23 -1
  69. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  70. package/lib/types/components/sessions/SessionListRow.js +52 -5
  71. package/lib/types/components/workspaces/HomeWorkspaceRow.d.ts +49 -0
  72. package/lib/types/components/workspaces/HomeWorkspaceRow.d.ts.map +1 -0
  73. package/lib/types/components/workspaces/HomeWorkspaceRow.js +50 -0
  74. package/lib/types/dsh-adapter/bundled-presets.d.ts +10 -0
  75. package/lib/types/dsh-adapter/bundled-presets.d.ts.map +1 -0
  76. package/lib/types/dsh-adapter/bundled-presets.js +80 -0
  77. package/lib/types/dsh-adapter/channel/action-readiness.d.ts +1 -1
  78. package/lib/types/dsh-adapter/channel/action-readiness.d.ts.map +1 -1
  79. package/lib/types/dsh-adapter/channel/action-readiness.js +3 -0
  80. package/lib/types/dsh-adapter/channel/agent-view-projection.d.ts +1 -1
  81. package/lib/types/dsh-adapter/channel/agent-view-projection.d.ts.map +1 -1
  82. package/lib/types/dsh-adapter/channel/agent-view-projection.js +19 -2
  83. package/lib/types/dsh-adapter/channel/background-action.d.ts.map +1 -1
  84. package/lib/types/dsh-adapter/channel/background-action.js +52 -29
  85. package/lib/types/dsh-adapter/channel/binding.d.ts +19 -2
  86. package/lib/types/dsh-adapter/channel/binding.d.ts.map +1 -1
  87. package/lib/types/dsh-adapter/channel/binding.js +78 -18
  88. package/lib/types/dsh-adapter/channel/composer-images.d.ts +5 -0
  89. package/lib/types/dsh-adapter/channel/composer-images.d.ts.map +1 -1
  90. package/lib/types/dsh-adapter/channel/composer-images.js +105 -6
  91. package/lib/types/dsh-adapter/channel/context-bookkeeping.d.ts +8 -0
  92. package/lib/types/dsh-adapter/channel/context-bookkeeping.d.ts.map +1 -1
  93. package/lib/types/dsh-adapter/channel/context-bookkeeping.js +3 -2
  94. package/lib/types/dsh-adapter/channel/ide-selection.d.ts +58 -0
  95. package/lib/types/dsh-adapter/channel/ide-selection.d.ts.map +1 -0
  96. package/lib/types/dsh-adapter/channel/ide-selection.js +226 -0
  97. package/lib/types/dsh-adapter/channel/input-delivery.d.ts +6 -1
  98. package/lib/types/dsh-adapter/channel/input-delivery.d.ts.map +1 -1
  99. package/lib/types/dsh-adapter/channel/input-delivery.js +9 -1
  100. package/lib/types/dsh-adapter/channel/local-actions.d.ts +3 -19
  101. package/lib/types/dsh-adapter/channel/local-actions.d.ts.map +1 -1
  102. package/lib/types/dsh-adapter/channel/local-actions.js +2 -1
  103. package/lib/types/dsh-adapter/channel/model-actions.d.ts +4 -1
  104. package/lib/types/dsh-adapter/channel/model-actions.d.ts.map +1 -1
  105. package/lib/types/dsh-adapter/channel/model-actions.js +82 -6
  106. package/lib/types/dsh-adapter/channel/model-switch.d.ts.map +1 -1
  107. package/lib/types/dsh-adapter/channel/model-switch.js +49 -31
  108. package/lib/types/dsh-adapter/channel/projection.d.ts +4 -0
  109. package/lib/types/dsh-adapter/channel/projection.d.ts.map +1 -1
  110. package/lib/types/dsh-adapter/channel/projection.js +29 -19
  111. package/lib/types/dsh-adapter/channel/reports.d.ts.map +1 -1
  112. package/lib/types/dsh-adapter/channel/reports.js +4 -12
  113. package/lib/types/dsh-adapter/channel/session-fork.d.ts.map +1 -1
  114. package/lib/types/dsh-adapter/channel/session-fork.js +26 -2
  115. package/lib/types/dsh-adapter/channel/session-live-adoption.d.ts +3 -0
  116. package/lib/types/dsh-adapter/channel/session-live-adoption.d.ts.map +1 -1
  117. package/lib/types/dsh-adapter/channel/session-live-adoption.js +1 -0
  118. package/lib/types/dsh-adapter/channel/session-metadata.d.ts +10 -0
  119. package/lib/types/dsh-adapter/channel/session-metadata.d.ts.map +1 -1
  120. package/lib/types/dsh-adapter/channel/session-metadata.js +2 -2
  121. package/lib/types/dsh-adapter/channel/session-resume.d.ts +11 -2
  122. package/lib/types/dsh-adapter/channel/session-resume.d.ts.map +1 -1
  123. package/lib/types/dsh-adapter/channel/session-resume.js +291 -94
  124. package/lib/types/dsh-adapter/channel/session-rewind.d.ts.map +1 -1
  125. package/lib/types/dsh-adapter/channel/session-rewind.js +34 -16
  126. package/lib/types/dsh-adapter/channel/session-tree-actions.d.ts.map +1 -1
  127. package/lib/types/dsh-adapter/channel/session-tree-actions.js +19 -3
  128. package/lib/types/dsh-adapter/channel/settings-host.d.ts.map +1 -1
  129. package/lib/types/dsh-adapter/channel/settings-host.js +4 -3
  130. package/lib/types/dsh-adapter/channel/state.d.ts +1 -1
  131. package/lib/types/dsh-adapter/channel/state.d.ts.map +1 -1
  132. package/lib/types/dsh-adapter/channel/state.js +1 -1
  133. package/lib/types/dsh-adapter/channel/transcript.d.ts.map +1 -1
  134. package/lib/types/dsh-adapter/channel/transcript.js +6 -12
  135. package/lib/types/dsh-adapter/channel/types.d.ts +6 -1
  136. package/lib/types/dsh-adapter/channel/types.d.ts.map +1 -1
  137. package/lib/types/dsh-adapter/channel/workspace-actions.d.ts +4 -1
  138. package/lib/types/dsh-adapter/channel/workspace-actions.d.ts.map +1 -1
  139. package/lib/types/dsh-adapter/channel/workspace-actions.js +41 -1
  140. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  141. package/lib/types/dsh-adapter/channel.js +67 -6
  142. package/lib/types/dsh-adapter/compat/messages.d.ts +15 -0
  143. package/lib/types/dsh-adapter/compat/messages.d.ts.map +1 -0
  144. package/lib/types/dsh-adapter/compat/messages.js +15 -0
  145. package/lib/types/dsh-adapter/compat/settings.d.ts +33 -0
  146. package/lib/types/dsh-adapter/compat/settings.d.ts.map +1 -0
  147. package/lib/types/dsh-adapter/compat/settings.js +43 -0
  148. package/lib/types/dsh-adapter/compat/shell.d.ts +25 -0
  149. package/lib/types/dsh-adapter/compat/shell.d.ts.map +1 -0
  150. package/lib/types/dsh-adapter/compat/shell.js +11 -0
  151. package/lib/types/dsh-adapter/contract.d.ts +4 -4
  152. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  153. package/lib/types/dsh-adapter/contract.js +15 -6
  154. package/lib/types/dsh-adapter/ide-channel.d.ts +224 -0
  155. package/lib/types/dsh-adapter/ide-channel.d.ts.map +1 -0
  156. package/lib/types/dsh-adapter/ide-channel.js +556 -0
  157. package/lib/types/dsh-adapter/index.d.ts +18 -3
  158. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  159. package/lib/types/dsh-adapter/index.js +32 -5
  160. package/lib/types/dsh-adapter/plugin.d.ts +5 -2
  161. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  162. package/lib/types/dsh-adapter/plugin.js +173 -42
  163. package/lib/types/dsh-adapter/preset-resolution.d.ts +8 -0
  164. package/lib/types/dsh-adapter/preset-resolution.d.ts.map +1 -1
  165. package/lib/types/dsh-adapter/preset-resolution.js +10 -0
  166. package/lib/types/dsh-adapter/session-mount-heartbeat.d.ts +53 -0
  167. package/lib/types/dsh-adapter/session-mount-heartbeat.d.ts.map +1 -0
  168. package/lib/types/dsh-adapter/session-mount-heartbeat.js +100 -0
  169. package/lib/types/dsh-adapter/sessionTree.d.ts.map +1 -1
  170. package/lib/types/dsh-adapter/sessionTree.js +3 -2
  171. package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
  172. package/lib/types/dsh-adapter/subagents.js +6 -5
  173. package/lib/types/dsh-adapter/workspaces.d.ts +46 -3
  174. package/lib/types/dsh-adapter/workspaces.d.ts.map +1 -1
  175. package/lib/types/dsh-adapter/workspaces.js +116 -0
  176. package/lib/types/effortPrefs.d.ts +15 -3
  177. package/lib/types/effortPrefs.d.ts.map +1 -1
  178. package/lib/types/effortPrefs.js +37 -3
  179. package/lib/types/homePrefs.d.ts +15 -0
  180. package/lib/types/homePrefs.d.ts.map +1 -0
  181. package/lib/types/homePrefs.js +53 -0
  182. package/lib/types/i18n.d.ts +257 -296
  183. package/lib/types/i18n.d.ts.map +1 -1
  184. package/lib/types/i18n.js +78 -84
  185. package/lib/types/ink/colorize.d.ts +10 -0
  186. package/lib/types/ink/colorize.d.ts.map +1 -1
  187. package/lib/types/ink/colorize.js +21 -0
  188. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  189. package/lib/types/ink/components/ScrollBox.js +12 -1
  190. package/lib/types/ink/dom.d.ts +1 -0
  191. package/lib/types/ink/dom.d.ts.map +1 -1
  192. package/lib/types/ink/hooks/use-copy-on-select.d.ts +5 -1
  193. package/lib/types/ink/hooks/use-copy-on-select.d.ts.map +1 -1
  194. package/lib/types/ink/hooks/use-copy-on-select.js +15 -1
  195. package/lib/types/ink/ink.d.ts.map +1 -1
  196. package/lib/types/ink/ink.js +36 -1
  197. package/lib/types/ink/output.d.ts +10 -1
  198. package/lib/types/ink/output.d.ts.map +1 -1
  199. package/lib/types/ink/output.js +109 -17
  200. package/lib/types/ink/render-border.d.ts +6 -1
  201. package/lib/types/ink/render-border.d.ts.map +1 -1
  202. package/lib/types/ink/render-border.js +25 -9
  203. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  204. package/lib/types/ink/render-node-to-output.js +48 -6
  205. package/lib/types/ink/renderer.d.ts +6 -0
  206. package/lib/types/ink/renderer.d.ts.map +1 -1
  207. package/lib/types/ink/renderer.js +2 -1
  208. package/lib/types/ink/selection.d.ts +60 -0
  209. package/lib/types/ink/selection.d.ts.map +1 -1
  210. package/lib/types/ink/selection.js +129 -0
  211. package/lib/types/ink/sixel-graphics.d.ts +19 -1
  212. package/lib/types/ink/sixel-graphics.d.ts.map +1 -1
  213. package/lib/types/ink/sixel-graphics.js +124 -12
  214. package/lib/types/ink/terminal-image.d.ts +18 -1
  215. package/lib/types/ink/terminal-image.d.ts.map +1 -1
  216. package/lib/types/ink/terminal.d.ts +17 -0
  217. package/lib/types/ink/terminal.d.ts.map +1 -1
  218. package/lib/types/ink/terminal.js +20 -1
  219. package/lib/types/screens/Chat.d.ts +17 -1
  220. package/lib/types/screens/Chat.d.ts.map +1 -1
  221. package/lib/types/screens/Chat.js +284 -72
  222. package/lib/types/screens/SessionSupervisor.d.ts +84 -0
  223. package/lib/types/screens/SessionSupervisor.d.ts.map +1 -0
  224. package/lib/types/screens/SessionSupervisor.js +350 -0
  225. package/lib/types/screens/SessionTree.js +2 -2
  226. package/lib/types/screens/Settings.d.ts.map +1 -1
  227. package/lib/types/screens/Settings.js +32 -9
  228. package/lib/types/screens/StatusLine.d.ts +13 -0
  229. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  230. package/lib/types/screens/StatusLine.js +36 -17
  231. package/lib/types/screens/StatusMetrics.d.ts +79 -22
  232. package/lib/types/screens/StatusMetrics.d.ts.map +1 -1
  233. package/lib/types/screens/StatusMetrics.js +153 -88
  234. package/lib/types/screens/sessionSupervisor/model.d.ts +83 -0
  235. package/lib/types/screens/sessionSupervisor/model.d.ts.map +1 -0
  236. package/lib/types/screens/sessionSupervisor/model.js +69 -0
  237. package/lib/types/screens/sessionSupervisor/useSessionSupervisor.d.ts +120 -0
  238. package/lib/types/screens/sessionSupervisor/useSessionSupervisor.d.ts.map +1 -0
  239. package/lib/types/screens/sessionSupervisor/useSessionSupervisor.js +593 -0
  240. package/lib/types/sessionMounts.d.ts +272 -0
  241. package/lib/types/sessionMounts.d.ts.map +1 -0
  242. package/lib/types/sessionMounts.js +706 -0
  243. package/lib/types/sessions/format.d.ts.map +1 -1
  244. package/lib/types/sessions/format.js +4 -1
  245. package/lib/types/sessions/resumeFailure.d.ts +38 -0
  246. package/lib/types/sessions/resumeFailure.d.ts.map +1 -0
  247. package/lib/types/sessions/resumeFailure.js +58 -0
  248. package/lib/types/terminal-utils/mermaid.d.ts +44 -0
  249. package/lib/types/terminal-utils/mermaid.d.ts.map +1 -0
  250. package/lib/types/terminal-utils/mermaid.js +83 -0
  251. package/lib/types/tips.d.ts.map +1 -1
  252. package/lib/types/tips.js +175 -19
  253. package/lib/types/tuiDisplayPrefs.d.ts +7 -9
  254. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  255. package/lib/types/tuiDisplayPrefs.js +31 -28
  256. package/lib/types/update.d.ts +61 -0
  257. package/lib/types/update.d.ts.map +1 -1
  258. package/lib/types/update.js +194 -0
  259. package/lib/types/utils/imageResize.d.ts +90 -0
  260. package/lib/types/utils/imageResize.d.ts.map +1 -0
  261. package/lib/types/utils/imageResize.js +272 -0
  262. package/lib/types/utils/keymap.d.ts +5 -1
  263. package/lib/types/utils/keymap.d.ts.map +1 -1
  264. package/lib/types/utils/keymap.js +23 -0
  265. package/lib/types/workspaces.d.ts +2 -1
  266. package/lib/types/workspaces.d.ts.map +1 -1
  267. package/lib/types/workspaces.js +1 -1
  268. package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +10 -10
  269. package/package.json +120 -103
  270. package/presets/liangshen/.dsh-tui-managed.json +1 -1
  271. package/presets/liangshen/agent.cordis.yml +15 -0
  272. package/lib/types/screens/AgentView.d.ts +0 -23
  273. package/lib/types/screens/AgentView.d.ts.map +0 -1
  274. package/lib/types/screens/AgentView.js +0 -546
  275. package/lib/types/screens/SessionBrowser.d.ts +0 -42
  276. package/lib/types/screens/SessionBrowser.d.ts.map +0 -1
  277. package/lib/types/screens/SessionBrowser.js +0 -911
package/bin/dsh-tui.js CHANGED
@@ -28,12 +28,12 @@
28
28
  * `DSH_TUI_LANG` 显式指定时从其值,否则默认中文(同 src/i18n.ts 的缺省)。
29
29
  */
30
30
  import { spawn, spawnSync } from 'node:child_process'
31
- import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs'
31
+ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs'
32
32
  import { homedir } from 'node:os'
33
33
  import { dirname, isAbsolute, join, resolve } from 'node:path'
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url'
35
35
 
36
- if (process.platform === 'win32' && process.env.DSH_TUI_STANDALONE_BINARY) {
36
+ if (process.platform === 'win32' && process.env.DSH_TUI_STANDALONE_BINARY && process.argv[2] !== 'safe') {
37
37
  try {
38
38
  const oldBinary = `${process.env.DSH_TUI_STANDALONE_BINARY}.old`
39
39
  if (existsSync(oldBinary)) rmSync(oldBinary, { force: true })
@@ -62,6 +62,19 @@ const ownPackage = readJson(join(ownDir, 'package.json'))
62
62
  const ownVersion = ownPackage?.name === '@deepseek-harness-tui/dsh-tui' ? ownPackage.version : undefined
63
63
  const PACKAGE = '@deepseek-harness-tui/dsh-tui'
64
64
  const PROFILE = 'dsh-tui'
65
+ // 救援 profile(最小可用):同 home 下的空白 profile(仅 base+TUI,无第三方
66
+ // 插件),是主 profile 装炸时的干净启动通道——创建走官方 dsh plugin add
67
+ // (钉当前版本,同 bootstrap 语义)。
68
+ // 「干净」在这里是启动器必须先证明的断言,不是宣传语:profile 维度由
69
+ // rescueProfileState() 校验根 manifest,home 维度由 homePatchFile 门禁
70
+ // 把关(见 createRescueProfile)。注意:即便救援自身的安装只落在
71
+ // profileDir 这个新目录里,任何一次 dsh 启动都会维护**共享**的
72
+ // `$DSH_HOME/profiles/node_modules` 模块回退链接(上游 dsh 的
73
+ // healProfilesModuleFallback,无开关),救援启动也不例外。
74
+ const RESCUE_PROFILE = 'dsh-tui-safe'
75
+ // 安装钉版本:ownVersion 缺失(bin 被单独拿走、package.json 不可读/改名)
76
+ // 时拼出 `@undefined` 只会让 add 失败得更晚、更难懂——退回 @latest。
77
+ const installVersion = ownVersion ?? 'latest'
65
78
 
66
79
  // --- 内联小工具(见文件头:零 lib 依赖是迁移契约的一部分)---------------------
67
80
  // 与 lib/types/utils/shellQuote.js 同语义的最小实现:cmd.exe 以空格拼接参数
@@ -131,16 +144,16 @@ const MSG = {
131
144
  zh: '[dsh-tui] 首次安装需要 pnpm(dsh plugin 会把安装转发给它):\n npm install -g pnpm (或启用 corepack:corepack enable pnpm)',
132
145
  },
133
146
  bootstrapStart: {
134
- en: `[dsh-tui] First run — initializing the ${PROFILE} profile (${PACKAGE}@${ownVersion})…`,
135
- zh: `[dsh-tui] 首次运行,正在初始化 ${PROFILE} profile(${PACKAGE}@${ownVersion})…`,
147
+ en: `[dsh-tui] First run — initializing the ${PROFILE} profile (${PACKAGE}@${installVersion})…`,
148
+ zh: `[dsh-tui] 首次运行,正在初始化 ${PROFILE} profile(${PACKAGE}@${installVersion})…`,
136
149
  },
137
150
  bootstrapRetryW: {
138
151
  en: '[dsh-tui] pnpm refused to add to the workspace root (ERR_PNPM_ADDING_TO_ROOT) — retrying with -w…',
139
152
  zh: '[dsh-tui] pnpm 拒绝写入 workspace 根(ERR_PNPM_ADDING_TO_ROOT)——带 -w 重试…',
140
153
  },
141
154
  installFailed: {
142
- en: `[dsh-tui] Plugin install failed. Retry manually later:\n dsh plugin --profile ${PROFILE} add -w ${PACKAGE}@${ownVersion}`,
143
- zh: `[dsh-tui] 插件安装失败。可稍后手工重试:\n dsh plugin --profile ${PROFILE} add -w ${PACKAGE}@${ownVersion}`,
155
+ en: `[dsh-tui] Plugin install failed. Retry manually later:\n dsh plugin --profile ${PROFILE} add -w ${PACKAGE}@${installVersion}`,
156
+ zh: `[dsh-tui] 插件安装失败。可稍后手工重试:\n dsh plugin --profile ${PROFILE} add -w ${PACKAGE}@${installVersion}`,
144
157
  },
145
158
  bootstrapUnreadable: {
146
159
  en: dir =>
@@ -170,6 +183,215 @@ const MSG = {
170
183
  en: code => `[dsh-tui] dsh profile exited with code ${code}. Run it directly for diagnostics:\n dsh --profile ${PROFILE}`,
171
184
  zh: code => `[dsh-tui] dsh profile 已退出(退出码 ${code})。可直接运行以下命令查看诊断:\n dsh --profile ${PROFILE}`,
172
185
  },
186
+ safeAsk: {
187
+ en: code => `dsh-tui exited unexpectedly (code ${code}). Enter safe mode? [Y/n] `,
188
+ zh: code => `dsh-tui 异常退出(码 ${code})。进入安全模式?[Y/n] `,
189
+ },
190
+ safeHint: {
191
+ en: code => `[dsh-tui] Exited with code ${code}. Run dsh-tui safe for diagnostics and repair guidance.`,
192
+ zh: code => `[dsh-tui] 异常退出(码 ${code})。可运行 dsh-tui safe 进入安全模式`,
193
+ },
194
+ safeRetrySignaled: {
195
+ en: signal => `[dsh-tui] retry signaled: ${signal}`,
196
+ zh: signal => `[dsh-tui] 重试被信号中断:${signal}`,
197
+ },
198
+ safeTitle: {
199
+ en: role => `dsh-tui safe · safe mode (read-only control plane) [${role}]`,
200
+ zh: role => `dsh-tui safe · 安全模式(控制面只读) [${role}]`,
201
+ },
202
+ safeIgnoredArgs: {
203
+ en: n => `[dsh-tui] ignored ${n} extra argument(s) after \`safe\``,
204
+ zh: n => `[dsh-tui] 已忽略附加参数:${n} 个`,
205
+ },
206
+ safeListUnreadable: {
207
+ en: reason => `Profile inventory unreadable (${reason}). See repair guidance below.`,
208
+ zh: reason => `清单不可读:<${reason}>。修复指引见下方。`,
209
+ },
210
+ safeGuideIntro: {
211
+ en: 'Repair commands (run them yourself — safe mode is read-only):',
212
+ zh: '修复命令(需自行执行——安全模式只读):',
213
+ },
214
+ safeRescueCreating: {
215
+ en: `[dsh-tui] Creating the blank rescue profile (${RESCUE_PROFILE})…`,
216
+ zh: `[dsh-tui] 正在创建空白救援 profile(${RESCUE_PROFILE})…`,
217
+ },
218
+ safeRescueExists: {
219
+ en: `[dsh-tui] Rescue profile already exists — starting it as-is.`,
220
+ zh: `[dsh-tui] 救援 profile 已存在——按现状直接启动。`,
221
+ },
222
+ safeRescueCreated: {
223
+ en: `[dsh-tui] Rescue profile created (base + TUI only, no third-party plugins) — starting it.`,
224
+ zh: `[dsh-tui] 救援 profile 已创建(仅 base + TUI,无第三方插件)——正在启动。`,
225
+ },
226
+ // 上面两条是**交互**路径的措辞(菜单选项 5 与 TTY 下的 `safe --rescue` 都经
227
+ // runRescue → startDshSession),说「正在启动」属实。非交互 `safe --rescue`
228
+ // 只做门禁 + 创建/复用就退出(没有终端可交接,见文件末尾该分支),一条会话
229
+ // 都不启动,故它单独用下面两条:不承诺启动,把启动动作交回用户。
230
+ safeRescueExistsNonInteractive: {
231
+ en: `[dsh-tui] Rescue profile already exists and is ready — nothing was started here (no terminal). Start it in a terminal: dsh --profile ${RESCUE_PROFILE}`,
232
+ zh: `[dsh-tui] 救援 profile 已存在且已就绪——此处没有终端,未启动任何会话。请在终端里启动:dsh --profile ${RESCUE_PROFILE}`,
233
+ },
234
+ safeRescueCreatedNonInteractive: {
235
+ en: `[dsh-tui] Rescue profile created (base + TUI only, no third-party plugins) and ready — nothing was started here (no terminal). Start it in a terminal: dsh --profile ${RESCUE_PROFILE}`,
236
+ zh: `[dsh-tui] 救援 profile 已创建(仅 base + TUI,无第三方插件)且已就绪——此处没有终端,未启动任何会话。请在终端里启动:dsh --profile ${RESCUE_PROFILE}`,
237
+ },
238
+ safeRescueFailed: {
239
+ en: detail => `[dsh-tui] Rescue profile creation failed (${detail}). See the diagnostics above and the guidance (option 4).`,
240
+ zh: detail => `[dsh-tui] 救援 profile 创建失败(${detail})。请看上方诊断与指引(选项 4)。`,
241
+ },
242
+ // 干净性门禁的三条拒绝文案:救援的前置是「可证明的干净」,证不出就不启动
243
+ // ——把路径与处置办法交给用户(安全模式只读,不代劳改文件)。
244
+ safeRescueHomePatch: {
245
+ en: path =>
246
+ `[dsh-tui] Rescue refused: the home-level patch layer exists:\n` +
247
+ ` ${path}\n` +
248
+ ` dsh applies that file over EVERY profile, so a broken layer here breaks the\n` +
249
+ ` rescue too — "clean" cannot be proven, and this launcher neither parses YAML\n` +
250
+ ` nor sees the composed result. Move or fix the file yourself (safe mode is\n` +
251
+ ` read-only), then retry.`,
252
+ zh: path =>
253
+ `[dsh-tui] 救援被拒绝:home 层补丁文件存在:\n` +
254
+ ` ${path}\n` +
255
+ ` dsh 把它叠加在每个 profile 之上,home 层坏掉时救援一起坏——「干净」\n` +
256
+ ` 无法证明(启动器既不解析 YAML 也拿不到组合结果)。请先自行移走或修好\n` +
257
+ ` 该文件(安全模式只读),再重试。`,
258
+ },
259
+ safeRescueUnrecognized: {
260
+ en: path =>
261
+ `[dsh-tui] Rescue refused: ${path} exists but is not a recognizable profile\n` +
262
+ ` (no valid root package.json), so it may belong to someone else. Refusing to\n` +
263
+ ` install into an unknown directory. Move or remove it yourself:\n` +
264
+ ` rm -rf ${path}`,
265
+ zh: path =>
266
+ `[dsh-tui] 救援被拒绝:${path} 已存在,但不是可识别的 profile(根\n` +
267
+ ` package.json 缺失或非法),可能是别的用途的目录。拒绝向未知目录安装。\n` +
268
+ ` 请自行移走或删除后重试:\n` +
269
+ ` rm -rf ${path}`,
270
+ },
271
+ safeRescueUnclean: {
272
+ en: (path, extras) =>
273
+ `[dsh-tui] Rescue refused: the existing ${RESCUE_PROFILE} profile declares third-party\n` +
274
+ ` plugins (${extras}), so starting it would not be clean:\n ${path}\n` +
275
+ ` Remove them yourself, or delete the profile and retry:\n` +
276
+ ` rm -rf ${path}`,
277
+ zh: (path, extras) =>
278
+ `[dsh-tui] 救援被拒绝:既有的 ${RESCUE_PROFILE} profile 声明了第三方插件\n` +
279
+ ` (${extras}),启动它就不是干净环境:\n ${path}\n` +
280
+ ` 请自行卸载它们,或删除该 profile 后重试:\n` +
281
+ ` rm -rf ${path}`,
282
+ },
283
+ // profile 层补丁(dsh 给每个新 profile 都写这个文件,默认只有注释 + []):
284
+ // 与 home 层同一类隐藏面,同样不解析 YAML——非默认内容一律拒绝。
285
+ safeRescuePatched: {
286
+ en: path =>
287
+ `[dsh-tui] Rescue refused: the profile's own patch layer carries entries:\n` +
288
+ ` ${path}\n` +
289
+ ` dsh composes that file into the profile after the bundle layers, and this\n` +
290
+ ` launcher neither parses YAML nor sees the composed result, so "clean" cannot\n` +
291
+ ` be proven. Empty the file (or move it aside) yourself, then retry.`,
292
+ zh: path =>
293
+ `[dsh-tui] 救援被拒绝:profile 自带的补丁层里有条目:\n` +
294
+ ` ${path}\n` +
295
+ ` dsh 会把该文件组合进 profile(排在 bundle 层之后),而启动器既不解析\n` +
296
+ ` YAML 也拿不到组合结果——「干净」无法证明。请自行清空该文件(或移走)\n` +
297
+ ` 后重试。`,
298
+ },
299
+ safeRescueStray: {
300
+ en: (path, stray) =>
301
+ `[dsh-tui] Rescue refused: ${path} holds entries this launcher did not generate\n` +
302
+ ` (${stray}), so removing the half-installed profile would delete your files.\n` +
303
+ ` Move them aside yourself, then retry (or delete the whole directory):\n` +
304
+ ` rm -rf ${path}`,
305
+ zh: (path, stray) =>
306
+ `[dsh-tui] 救援被拒绝:${path} 里有不是本启动器生成的条目(${stray}),\n` +
307
+ ` 清理半装状态会连你的文件一起删掉,因此不代劳。请先自行移走,再重试\n` +
308
+ ` (或整个删除该目录):\n` +
309
+ ` rm -rf ${path}`,
310
+ },
311
+ safeRescueCleanup: {
312
+ en: path =>
313
+ `[dsh-tui] removed the half-installed profile (only dsh/pnpm-generated files were\n` +
314
+ ` present, checked before removing) so the next attempt starts clean:\n ${path}`,
315
+ zh: path =>
316
+ `[dsh-tui] 已清理半装的 profile(删除前已确认目录里只有 dsh/pnpm 生成的文件),\n` +
317
+ ` 下次尝试将从零开始:\n ${path}`,
318
+ },
319
+ safeRescueRemoveFailed: {
320
+ en: (path, reason) =>
321
+ `[dsh-tui] could not remove the half-installed profile:\n ${path}\n ${reason}\n` +
322
+ ` Remove it yourself, then retry.`,
323
+ zh: (path, reason) =>
324
+ `[dsh-tui] 无法删除半装的 profile:\n ${path}\n ${reason}\n` +
325
+ ` 请自行删除后重试。`,
326
+ },
327
+ safeMenuLabels: {
328
+ en: {
329
+ retry: 'Retry normal startup',
330
+ doctor: 'Run environment diagnostics',
331
+ inventory: 'Show profile plugin inventory (read-only)',
332
+ guide: 'Show repair command guidance',
333
+ rescue: 'Create blank rescue profile and start clean (dsh-tui-safe)',
334
+ exit: code => `Exit (exit code ${code})`,
335
+ prompt: 'safe> ',
336
+ invalid: 'Invalid choice — enter 1-6:',
337
+ replaySource: ' (replay)',
338
+ coldStartSource: ' (cold start)',
339
+ bundlesHeader: 'Composition layers (dsh.profile.bundles, ordered):',
340
+ depsHeader: 'Direct dependencies (uninstallable candidates marked 3rd-party):',
341
+ builtin: 'builtin',
342
+ third: '3rd-party',
343
+ },
344
+ zh: {
345
+ retry: '重试正常启动',
346
+ doctor: '运行环境诊断',
347
+ inventory: '查看 profile 插件清单(只读)',
348
+ guide: '显示修复命令指引',
349
+ rescue: '创建空白救援 profile 并干净启动(dsh-tui-safe)',
350
+ exit: code => `退出(退出码 ${code})`,
351
+ prompt: 'safe> ',
352
+ invalid: '无效选择——请输入 1-6:',
353
+ replaySource: '(重放)',
354
+ coldStartSource: '(冷启动)',
355
+ bundlesHeader: '组合层(dsh.profile.bundles,有序):',
356
+ depsHeader: '直接依赖(第三方为可卸载候选):',
357
+ builtin: '内置',
358
+ third: '第三方',
359
+ },
360
+ },
361
+ // safe 指引与清单降级原因的文案(renderGuide / renderInventory 取用):
362
+ // zh 为收编前的字面文案;`@<版本>` 占位以 versionPlaceholder 拼接。
363
+ safeGuideLabels: {
364
+ en: {
365
+ missingReason: 'package.json missing or corrupt',
366
+ fieldsReason: 'required fields missing or wrong type',
367
+ notReadyReason: 'profile not ready (missing or half-installed)',
368
+ uninstallThird: ' # Remove third-party plugins (one by one):',
369
+ nothingThird: ' # No third-party direct dependencies to uninstall',
370
+ reinstallTui: ' # Reinstall/align the TUI (see dsh-tui doctor for the version):',
371
+ versionPlaceholder: '<version>',
372
+ diagnostics: ' # Environment diagnostics:',
373
+ globalUpgrade: ' # Global upgrade when the launcher is too old:',
374
+ rescueProfile: ' # Rescue profile (blank, no third-party plugins; also menu option 5):',
375
+ homePatch: path =>
376
+ ` # Note: the home-level patch ${path}\n` +
377
+ ` # is applied over every profile, the rescue one included — check it first`,
378
+ },
379
+ zh: {
380
+ missingReason: 'package.json 缺失或损坏',
381
+ fieldsReason: '必需字段缺失或类型错误',
382
+ notReadyReason: 'profile 未就绪(未安装或残缺)',
383
+ uninstallThird: ' # 卸载第三方插件(逐个执行):',
384
+ nothingThird: ' # 无第三方直接依赖可卸载',
385
+ reinstallTui: ' # 重装/对齐 TUI(版本见 dsh-tui doctor):',
386
+ versionPlaceholder: '<版本>',
387
+ diagnostics: ' # 环境诊断:',
388
+ globalUpgrade: ' # 启动器过旧时的全局升级:',
389
+ rescueProfile: ' # 救援 profile(空白,无第三方插件;也可用菜单选项 5 一键创建并启动):',
390
+ homePatch: path =>
391
+ ` # 注意:home 层补丁 ${path}\n` +
392
+ ` # 会叠加到每个 profile(含救援)之上;救援起不来时先查它`,
393
+ },
394
+ },
173
395
  notInstalled: {
174
396
  en: '(not installed)',
175
397
  zh: '(未安装)',
@@ -216,6 +438,8 @@ const MSG = {
216
438
  `Commands:\n` +
217
439
  ` update Update the ${PROFILE} profile to the latest release\n` +
218
440
  ` doctor Pre-flight environment checks (dsh/pnpm/profile/key)\n` +
441
+ ` safe Safe mode: read-only diagnostics, inventory, repair guidance\n` +
442
+ ` safe --rescue Create/verify the clean rescue profile (starts it in a terminal)\n` +
219
443
  ` version Show launcher and profile versions\n` +
220
444
  ` help Show this help\n\n` +
221
445
  `Options:\n` +
@@ -228,6 +452,8 @@ const MSG = {
228
452
  `命令:\n` +
229
453
  ` update 将 ${PROFILE} profile 升级到最新版本\n` +
230
454
  ` doctor 启动前环境诊断(dsh/pnpm/profile/密钥)\n` +
455
+ ` safe 安全模式:只读诊断、插件清单与修复指引\n` +
456
+ ` safe --rescue 创建/校验干净的救援 profile(有终端时随即启动它)\n` +
231
457
  ` version 显示启动器与 profile 版本\n` +
232
458
  ` help 显示本帮助\n\n` +
233
459
  `选项:\n` +
@@ -257,6 +483,36 @@ const profilePkgDir = join(profileDir, 'node_modules', '@deepseek-harness-tui',
257
483
  const profileBin = join(profilePkgDir, 'bin', 'dsh-tui.js')
258
484
  const installedPkgPath = join(profilePkgDir, 'package.json')
259
485
  const runningInsideProfile = sameDir(ownDir, profilePkgDir)
486
+ const rescueProfileDir = join(dshHome, 'profiles', RESCUE_PROFILE)
487
+ const rescueInstalledPkg = join(rescueProfileDir, 'node_modules', '@deepseek-harness-tui', 'dsh-tui', 'package.json')
488
+ // profile 层补丁文件:dsh 给**每个**新建 profile 都写它(默认只有注释与 `[]`),
489
+ // 并在 bundle 层之后把它组合进 profile(dsh-app-boot `loadProfile` →
490
+ // `composed.profile.patches`)。它与下面的 home 层是同一类隐藏面——见
491
+ // trivialPatchLayer 的判定理由。
492
+ const rescuePatchFile = join(rescueProfileDir, 'cordis.patch.yml')
493
+ // 上游 dsh 的 home 层:homePatches 排在 bundle 层与 profile 层**之后**,叠加到
494
+ // **每个** profile 上(dsh profile-boot → composeProfile);且「存在但解析不了
495
+ // 或不是数组」的补丁文件按设计 fail loud(dsh-app-boot → loadOptionalPatches)。
496
+ // 启动器既不解析 YAML 也拿不到组合结果 → 该文件存在时「干净救援」不可证明。
497
+ const homePatchFile = join(dshHome, 'cordis.patch.yml')
498
+ // 救援目录里允许存在的条目:全是 dsh / pnpm 为这个 profile 生成的东西。
499
+ // 清理(半装、no-op 假成功)只在没有别的条目时进行——不静默删用户放进去的
500
+ // 文件:发现未知条目就拒绝,把名字与处置办法交给用户。
501
+ // 分成目录/文件两组是因为**形态也要校验**:只比顶层名字的话,「把 cordis.yml
502
+ // 做成目录再往里放东西」会被当成生成物一起删掉(复测 S6 实测)。
503
+ // `.dsh-module-fallback` 不是猜的:dsh 在**每次 profile 启动**时都会建
504
+ // `<profile>/.dsh-module-fallback/node_modules`(dsh-app-boot
505
+ // `healProfileModuleFallback` → mkdirSync(ownedModulesDir)),而「装坏了 →
506
+ // 启动过一次 → 再来救援」正是这条路径要处理的场景;不认它就会把自愈路径堵死,
507
+ // 用户按提示移走后下次启动又会被重新生成。
508
+ const GENERATED_DIR_ENTRIES = new Set(['node_modules', '.dsh-module-fallback'])
509
+ const GENERATED_FILE_ENTRIES = new Set([
510
+ 'package.json',
511
+ 'pnpm-lock.yaml',
512
+ 'pnpm-workspace.yaml',
513
+ 'cordis.patch.yml',
514
+ 'cordis.yml',
515
+ ])
260
516
 
261
517
  // ─── 子命令:version / help ──────────────────────────────────────────────────
262
518
  // 只认第一个参数,且在角色分支之前应答:两种角色都不经过委托与自举——
@@ -299,17 +555,17 @@ const credentialRefDeclared = (home, name) => {
299
555
  }
300
556
  }
301
557
 
302
- // ─── 子命令:doctor ──────────────────────────────────────────────────────────
303
- // 启动前环境诊断——针对「TUI 起不来」的故障域(装不上、update 后版本不
304
- // 同步、密钥没配),与 TUI 内 /doctor 的会话内诊断互补。零 lib 依赖、
305
- // 不委托、不自举:profile 残缺时它必须还能跑。密钥红线:只报告是否已
306
- // 设置,绝不输出值。仅 dsh 缺失记为硬失败(其余检查全部照常打印后再
307
- // 以退出码 1 收束)。
308
- if (subcommand === 'doctor') {
558
+ // ─── doctor 检查逻辑(doctor 子命令与 safe 会话共用)──────────────────────────
559
+ // 输出与退出语义与单命令时代逐字一致:版本探针白名单回显、密钥只报
560
+ // truthiness(env 或凭据库 ref 任一命中即视为已设置)、仅 dsh 缺失为硬
561
+ // 失败。safe 复用同一函数——两个入口的 diagnostics 不许分叉(对齐 doctor
562
+ // 与 TUI 内 /doctor 的既有契约)。
563
+ const runDoctorChecks = () => {
309
564
  const L = msg('doctorLabels')
565
+ const lines = []
310
566
  let hardFailure = false
311
- const report = (ok, label, detail) => console.log(`${ok ? '✓' : '✗'} ${label}: ${detail}`)
312
- console.log(`dsh-tui doctor · ${PACKAGE} ${ownVersion ?? 'unknown'}`)
567
+ const report = (ok, label, detail) => lines.push(`${ok ? '✓' : '✗'} ${label}: ${detail}`)
568
+ lines.push(`dsh-tui doctor · ${PACKAGE} ${ownVersion ?? 'unknown'}`)
313
569
  report(true, 'node', `${process.version} · ${process.platform} ${process.arch}`)
314
570
  const probeVersion = command => {
315
571
  const probe = spawnSync(...cmd(command, ['--version']), { stdio: 'pipe', encoding: 'utf8', ...shellOpt })
@@ -358,6 +614,79 @@ if (subcommand === 'doctor') {
358
614
  for (const candidate of [join(homedir(), '.dsh-tui', 'cordis.yml'), join(profileDir, 'cordis.patch.yml')]) {
359
615
  report(existsSync(candidate), 'config', `${candidate}${existsSync(candidate) ? '' : ` ${L.missing}`}`)
360
616
  }
617
+ return { hardFailure, lines }
618
+ }
619
+ // ─── safe 会话支撑(清单解析与报告渲染,交互/非交互共用)──────────────────────
620
+ // 保护包:组合层模板与 TUI 本体,不进入卸载候选。两维度分类是 PR② 卸载
621
+ // 功能将复用的唯一分类规则,不得合并简化(spec §6.1)。
622
+ const PROTECTED_PLUGINS = new Set(['@deepseek-ai/dsh-base', PACKAGE])
623
+ // dir 参数供救援 profile 复用同一套解析规则(两处 manifest 契约不许分叉)。
624
+ const readProfileInventory = (dir = profileDir) => {
625
+ const pkg = readJson(join(dir, 'package.json'))
626
+ if (pkg === undefined || typeof pkg !== 'object') return { error: 'missing' }
627
+ const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles.filter(x => typeof x === 'string') : undefined
628
+ const deps = pkg?.dependencies && typeof pkg.dependencies === 'object' && !Array.isArray(pkg.dependencies)
629
+ ? Object.keys(pkg.dependencies)
630
+ : undefined
631
+ if (bundles === undefined || deps === undefined) return { error: 'fields' }
632
+ return { bundles, deps }
633
+ }
634
+ const renderInventory = lines => {
635
+ const L = msg('safeMenuLabels')
636
+ const G = msg('safeGuideLabels')
637
+ const inv = readProfileInventory()
638
+ if (inv.error) {
639
+ lines.push(msg('safeListUnreadable')(inv.error === 'missing' ? G.missingReason : G.fieldsReason))
640
+ return
641
+ }
642
+ lines.push(L.bundlesHeader)
643
+ for (const b of inv.bundles) lines.push(` · ${b} (${L.builtin})`)
644
+ lines.push(L.depsHeader)
645
+ for (const d of inv.deps) lines.push(` · ${d} (${PROTECTED_PLUGINS.has(d) ? L.builtin : L.third})`)
646
+ }
647
+ const renderGuide = lines => {
648
+ const L = msg('safeGuideLabels')
649
+ lines.push(msg('safeGuideIntro'))
650
+ const inv = readProfileInventory()
651
+ const third = inv.error ? [] : inv.deps.filter(d => !PROTECTED_PLUGINS.has(d))
652
+ if (third.length > 0) {
653
+ lines.push(L.uninstallThird)
654
+ for (const d of third) lines.push(` dsh plugin --profile ${PROFILE} remove ${d}`)
655
+ } else {
656
+ lines.push(L.nothingThird)
657
+ }
658
+ lines.push(L.reinstallTui)
659
+ lines.push(` dsh plugin --profile ${PROFILE} add ${PACKAGE}@${L.versionPlaceholder}`)
660
+ lines.push(L.diagnostics)
661
+ lines.push(` dsh-tui doctor`)
662
+ lines.push(L.globalUpgrade)
663
+ lines.push(` npm install -g --legacy-peer-deps ${PACKAGE}@${L.versionPlaceholder}`)
664
+ lines.push(L.rescueProfile)
665
+ lines.push(` dsh plugin --profile ${RESCUE_PROFILE} add ${PACKAGE}@${L.versionPlaceholder}`)
666
+ lines.push(` dsh --profile ${RESCUE_PROFILE}`)
667
+ // home 层是救援唯一的隐藏前置:该文件叠加到每个 profile 上,出问题时
668
+ // 连救援一起起不来。指引先说清,用户不会把救援失败误判成救援本身坏了。
669
+ if (existsSync(homePatchFile)) lines.push(L.homePatch(homePatchFile))
670
+ }
671
+ const renderSafeReport = extraLines => {
672
+ const lines = []
673
+ lines.push(msg('safeTitle')(runningInsideProfile ? 'profile' : 'launcher'))
674
+ for (const l of extraLines ?? []) lines.push(l)
675
+ const { lines: doctorLines } = runDoctorChecks()
676
+ for (const l of doctorLines) lines.push(l)
677
+ renderInventory(lines)
678
+ renderGuide(lines)
679
+ return lines
680
+ }
681
+ // ─── 子命令:doctor ──────────────────────────────────────────────────────────
682
+ // 启动前环境诊断——针对「TUI 起不来」的故障域(装不上、update 后版本不
683
+ // 同步、密钥没配),与 TUI 内 /doctor 的会话内诊断互补。零 lib 依赖、
684
+ // 不委托、不自举:profile 残缺时它必须还能跑。密钥红线:只报告是否已
685
+ // 设置,绝不输出值。仅 dsh 缺失记为硬失败(其余检查全部照常打印后再
686
+ // 以退出码 1 收束)。
687
+ if (subcommand === 'doctor') {
688
+ const { hardFailure, lines } = runDoctorChecks()
689
+ for (const line of lines) console.log(line)
361
690
  process.exit(hardFailure ? 1 : 0)
362
691
  }
363
692
 
@@ -376,6 +705,378 @@ const forwardExit = child => {
376
705
  })
377
706
  }
378
707
 
708
+ // ─── dsh 会话结果模型(fallback 与 safe 重试共用)─────────────────────────────
709
+ // 统一表示子进程结局;不在此处做任何退出决定——退出权在调用者(首启结算
710
+ // 或 safe 菜单)。Windows 经 cmd()/shell:true 启动(见 cmd 注释),壳层
711
+ // 观察到的 signal 不保证等同内部 dsh 的中断语义:判定一律只看数值 code,
712
+ // 不从数值反推信号(spec §5.1)。
713
+ const startDshSession = (dshArgs, profile = PROFILE, env = process.env) =>
714
+ new Promise(resolve => {
715
+ const child = spawn(...cmd('dsh', ['--profile', profile, ...dshArgs]), {
716
+ stdio: 'inherit',
717
+ env,
718
+ ...shellOpt,
719
+ })
720
+ child.on('error', err => resolve({ kind: 'error', error: err }))
721
+ child.on('exit', (code, signal) => {
722
+ if (signal) resolve({ kind: 'signal', signal })
723
+ else resolve({ kind: 'exit', code: code ?? 0 })
724
+ })
725
+ })
726
+
727
+ // 救援子进程的环境:显式构造,而不是把宿主 process.env 原样交给它。救援的
728
+ // 语义是「干净冷启动」,而启动器自己写进 process.env 的会话控制变量会把刚
729
+ // 崩掉的主 profile 的会话 id / 工作区目标带进救援——救援 profile 里并不存在
730
+ // 那个会话,dsh 会在恢复时再报一次错,正好污染最该干净的通道。其余宿主变量
731
+ // (PATH / DSH_HOME / 凭据…)是救援能工作的前提,照常继承。
732
+ // home 层补丁不在此列:它由 createRescueProfile 的干净性门禁单独把关。
733
+ const RESCUE_DROPPED_ENV = ['DSH_TUI_RESUME_SESSION', 'DSH_TUI_WORKSPACE_TARGET']
734
+ const rescueEnv = () => {
735
+ const env = { ...process.env }
736
+ for (const key of RESCUE_DROPPED_ENV) delete env[key]
737
+ return env
738
+ }
739
+
740
+ // TTY 判定:询问与菜单都要求 stdin/stdout 均可交互(readline 需要 stdin,
741
+ // 菜单可读需要 stdout);任一非 TTY(脚本/管道/headless 宿主)走降级。
742
+ const isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY)
743
+
744
+ // 子进程异常退出后终端可能停在脏状态(alt-screen/鼠标/隐藏光标——清理
745
+ // 责任在 TUI 的 ink 退出路径,不保证完成)。进入询问/菜单前做最小恢复,
746
+ // 仅为让后续界面可读,不承诺完整复原(spec §6.2)。
747
+ const restoreTerminalMinimal = () => {
748
+ process.stdout.write('\x1b[?1049l\x1b[?1000l\x1b[?1006l\x1b[?25h')
749
+ }
750
+
751
+ const askSafeEntry = async pendingExitCode => {
752
+ restoreTerminalMinimal()
753
+ const readline = (await import('node:readline/promises')).default
754
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
755
+ let cancelled = false
756
+ rl.on('SIGINT', () => { cancelled = true; rl.close() })
757
+ // 与 runSafeSession.askChoice 同款 close 竞速:接口 close(上方 SIGINT
758
+ // 处理器主动 close,或 TTY 的 Ctrl+D/EOF)时 question 可能永不结算——
759
+ // 裸 await 会让 fallback 询问挂死(PTY 实测 Ctrl+C 下顶层 await 以退出
760
+ // 码 13 异常中止)。close 一律按取消,对齐 spec §6.2:Ctrl+C/Ctrl+D/EOF
761
+ // 等价拒绝(按原退出码收束)。
762
+ let answer
763
+ try {
764
+ answer = await Promise.race([
765
+ rl.question(msg('safeAsk')(pendingExitCode)),
766
+ new Promise((_, reject) => rl.once('close', () => reject(new Error('closed')))),
767
+ ])
768
+ } catch { cancelled = true }
769
+ try { rl.close() } catch { /* 已关闭 */ }
770
+ if (cancelled || answer === undefined) return false
771
+ const a = answer.trim().toLowerCase()
772
+ return a === '' || a === 'y' || a === 'yes'
773
+ }
774
+
775
+ const runSafeSession = async ({ pendingExitCode = 0, retryDsh, extraLines, rescueFirst = false } = {}) => {
776
+ const L = msg('safeMenuLabels')
777
+ let exitCode = pendingExitCode
778
+ const readline = (await import('node:readline/promises')).default
779
+ const doctorLines = () => runDoctorChecks().lines
780
+ // 询问态跨问句共享:cancelled 只由用户中断(SIGINT/EOF)置位。handingOff
781
+ // 在重试交接期置位;当前流程有效选择返回时接口已关闭,SIGINT/close 两处
782
+ // handingOff 守卫实际不可达,属防御性——防未来流程中接口存活跨入交接期
783
+ // 时,主动交接被误记为用户取消。
784
+ const state = { cancelled: false, handingOff: false }
785
+ const printMenu = () => {
786
+ // 回到菜单前先恢复终端:选项 1/5 启动的子进程可能带着备用屏/隐藏光标
787
+ // 异常退出(TUI 的 ink 退出路径不保证跑完,父进程又是非零退出),不恢复
788
+ // 就是「菜单与诊断一起看不见」——用户最需要看诊断的时刻恰好黑屏。
789
+ restoreTerminalMinimal()
790
+ console.log(msg('safeTitle')(runningInsideProfile ? 'profile' : 'launcher'))
791
+ for (const l of extraLines ?? []) console.log(l)
792
+ for (const l of doctorLines()) console.log(l)
793
+ console.log(` 1) ${L.retry}`)
794
+ console.log(` 2) ${L.doctor}`)
795
+ console.log(` 3) ${L.inventory}`)
796
+ console.log(` 4) ${L.guide}`)
797
+ console.log(` 5) ${L.rescue}`)
798
+ console.log(` 6) ${L.exit(exitCode)}`)
799
+ }
800
+ const askChoice = async () => {
801
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
802
+ rl.on('SIGINT', () => { if (!state.handingOff) { state.cancelled = true; rl.close() } })
803
+ // readline/promises 的 question 在接口 close(EOF/Ctrl+D,或上方 SIGINT
804
+ // 处理器主动 close)时 promise 永不结算(Node v24 实测)——裸 await 会
805
+ // 挂死。与 close 事件竞速,close 一律按用户取消收束。
806
+ let value
807
+ try {
808
+ value = await Promise.race([
809
+ rl.question(L.prompt),
810
+ new Promise((_, reject) => rl.once('close', () => reject(new Error('closed')))),
811
+ ])
812
+ } catch { state.cancelled = true }
813
+ if (!state.cancelled && !state.handingOff) { try { rl.close() } catch { /* 已关闭 */ } }
814
+ return { cancelled: state.cancelled || value === undefined, value: String(value ?? '').trim() }
815
+ }
816
+ // 重试结果结算(重放与冷启动两来源共用):exit 0 → 返回 0 收束会话;
817
+ // 非零 exit → 更新退出码、保留 profileExited 诊断回菜单;signal → 提示
818
+ // 后回菜单;error → launchFailed 后回菜单。返回 null 表示回菜单,菜单内
819
+ // 不自动重试(防死循环:询问只发生在外层首次非零退出)。
820
+ const settleRetry = result => {
821
+ if (result.kind === 'exit') {
822
+ if (result.code === 0) return 0
823
+ exitCode = result.code
824
+ console.error(msg('profileExited')(result.code))
825
+ return null
826
+ }
827
+ if (result.kind === 'signal') {
828
+ console.error(msg('safeRetrySignaled')(result.signal))
829
+ return null
830
+ }
831
+ console.error(msg('launchFailed')(result.error))
832
+ return null
833
+ }
834
+ // 无效输入重提示上限 3 次,之后重印完整菜单继续等待;有效选择后计数重置。
835
+ const INVALID_LIMIT = 3
836
+ // `safe --rescue`:进菜单前先执行一次救援动作(与选项 5 同一实现)。
837
+ if (rescueFirst) {
838
+ state.handingOff = true
839
+ const result = await runRescue()
840
+ state.handingOff = false
841
+ if (result !== null) {
842
+ const settled = settleRetry(result)
843
+ if (settled !== null) return settled
844
+ }
845
+ }
846
+ for (;;) {
847
+ printMenu()
848
+ let invalid = 0
849
+ let choice = ''
850
+ for (;;) {
851
+ const { cancelled, value } = await askChoice()
852
+ if (cancelled) { choice = '6'; break }
853
+ if (['1', '2', '3', '4', '5', '6'].includes(value)) { choice = value; break }
854
+ invalid++
855
+ if (invalid >= INVALID_LIMIT) break
856
+ console.log(L.invalid)
857
+ }
858
+ if (choice === '') continue // 达无效上限:重印菜单继续等待(计数随轮重置)
859
+ if (choice === '1') {
860
+ // 分支开头区分来源提示:replay = fallback 携带的首启规范化 args 重放;
861
+ // cold start = 手动入口无已规范化 args,按空参数冷启动。
862
+ const replay = typeof retryDsh === 'function'
863
+ console.log(L.retry + (replay ? L.replaySource : L.coldStartSource))
864
+ // 两来源共用 profileReady 前置(spec §4:重试不得隐式自举)。
865
+ if (!profileReady()) { console.error(msg('safeListUnreadable')(msg('safeGuideLabels').notReadyReason)); continue }
866
+ state.handingOff = true // 主动交接:此刻起的中断不算用户取消
867
+ const settled = settleRetry(replay ? await retryDsh() : await startDshSession([]))
868
+ state.handingOff = false
869
+ if (settled !== null) return settled
870
+ continue
871
+ }
872
+ if (choice === '2') { for (const l of doctorLines()) console.log(l); continue }
873
+ if (choice === '3') { const lines = []; renderInventory(lines); for (const l of lines) console.log(l); continue }
874
+ if (choice === '4') { const lines = []; renderGuide(lines); for (const l of lines) console.log(l); continue }
875
+ if (choice === '5') {
876
+ // 救援动作(最小可用):先展示 doctor 诊断(决策依据),再创建/复用
877
+ // 空白救援 profile 并干净启动。被干净性门禁拒绝或创建失败时回菜单并
878
+ // 打印原因;启动结果与重试同一结算语义(exit 0 结束会话,其余回菜单)。
879
+ state.handingOff = true
880
+ const result = await runRescue()
881
+ state.handingOff = false
882
+ if (result === null) continue
883
+ const settled = settleRetry(result)
884
+ if (settled !== null) return settled
885
+ continue
886
+ }
887
+ return exitCode
888
+ }
889
+ }
890
+
891
+ // 救援动作本体(菜单选项 5、`safe --rescue` 与首启后的显式救援共用):
892
+ // 先给 doctor 诊断,再门禁 + 创建/复用,成功才用显式构造的环境启动。
893
+ // 返回子进程结果;被门禁拒绝或创建失败返回 null(原因已就地打印)。
894
+ const runRescue = async () => {
895
+ for (const l of runDoctorChecks().lines) console.log(l)
896
+ const created = createRescueProfile()
897
+ if (created.kind !== 'created' && created.kind !== 'exists') {
898
+ for (const l of created.lines) console.error(l)
899
+ return null
900
+ }
901
+ console.log(created.kind === 'exists' ? msg('safeRescueExists') : msg('safeRescueCreated'))
902
+ return startDshSession([], RESCUE_PROFILE, rescueEnv())
903
+ }
904
+
905
+ // profile 层补丁文件的判定:dsh 自己给每个新建 profile 生成的文件形如
906
+ // 「注释 + `[]`」,那是「没有补丁层」;除此以外的任何内容都不可证明干净。
907
+ // 这里不做 YAML 解析,只做一件事:剥掉 `#` 注释行后看剩下什么——`[]`/空
908
+ // 才算无补丁层,其余(含解析不了的残缺内容)一律拒绝。方向是 fail-closed:
909
+ // 判错只会多拒一次,不会放过一个带有条目的层。
910
+ // 与 home 层的差别是有意的:home 层只要存在就拒绝(它是用户手写的、只在这台
911
+ // 机器上生效的偏好层),而 profile 层是 dsh 每次新建 profile 都会写的默认
912
+ // 文件——按存在即拒绝会让救援在第一次创建后就永久不可用。
913
+ const trivialPatchLayer = path => {
914
+ if (!existsSync(path)) return true
915
+ try {
916
+ const body = readFileSync(path, 'utf8')
917
+ .split('\n')
918
+ .map(line => line.replace(/#.*$/u, '').trim())
919
+ .join('')
920
+ return body === '' || body === '[]'
921
+ } catch {
922
+ return false
923
+ }
924
+ }
925
+ // 救援目录里除「dsh/pnpm 生成物」之外的条目:名字不在白名单、或形态不符
926
+ // (该是目录的成了文件、该是文件的成了目录或链接)都算未知条目;读不到目录时
927
+ // 同样按未知处理。只查顶层——不递归枚举 `node_modules` 里的内容(那是过度
928
+ // 设计,而 dsh 自己也在里面放链接);这些目录**内部**的内容会随目录一起删掉,
929
+ // README 已按这个口径写明。
930
+ const rescueDirStrays = () => {
931
+ try {
932
+ const strays = []
933
+ for (const entry of readdirSync(rescueProfileDir, { withFileTypes: true })) {
934
+ if (GENERATED_DIR_ENTRIES.has(entry.name)) {
935
+ if (!entry.isDirectory()) strays.push(`${entry.name} (expected a directory)`)
936
+ } else if (GENERATED_FILE_ENTRIES.has(entry.name)) {
937
+ if (!entry.isFile()) strays.push(`${entry.name} (expected a file)`)
938
+ } else {
939
+ strays.push(entry.name)
940
+ }
941
+ }
942
+ return strays
943
+ } catch {
944
+ return ['<unreadable>']
945
+ }
946
+ }
947
+ /**
948
+ * Remove the half-installed rescue profile.
949
+ *
950
+ * Only called once the directory is known to hold nothing but generated files
951
+ * (see `rescueDirStrays`) or right after this function created it. A failure
952
+ * (EBUSY/EPERM — Windows indexers and AV hold directory handles briefly) is
953
+ * returned for the caller to report instead of throwing a stack: the direction
954
+ * stays fail-closed, the message stays readable.
955
+ * @returns The error, or undefined on success.
956
+ */
957
+ const removeRescueDir = () => {
958
+ try {
959
+ rmSync(rescueProfileDir, { recursive: true, force: true })
960
+ return undefined
961
+ } catch (error) {
962
+ return error
963
+ }
964
+ }
965
+
966
+ // 救援 profile 的干净性判定:
967
+ // absent 目录不存在 → 可创建
968
+ // unrecognized 目录存在但不是可识别 profile → 拒绝(绝不能往里装)
969
+ // unclean 根 manifest 声明了第三方插件 → 拒绝(启动它就不干净)
970
+ // patched 自带补丁层里有条目 → 拒绝(与 home 层同款:组合结果不可证明)
971
+ // clean 只含受保护插件且无补丁条目 → 可安装/可复用
972
+ // 根 manifest 只覆盖「装了什么插件」这一维;补丁层是另一维,两者都要过。
973
+ const rescueProfileState = () => {
974
+ if (!existsSync(rescueProfileDir)) return { state: 'absent' }
975
+ const inv = readProfileInventory(rescueProfileDir)
976
+ if (inv.error) return { state: 'unrecognized' }
977
+ const extras = [...inv.bundles, ...inv.deps].filter(name => !PROTECTED_PLUGINS.has(name))
978
+ if (extras.length > 0) return { state: 'unclean', extras }
979
+ if (!trivialPatchLayer(rescuePatchFile)) return { state: 'patched' }
980
+ return { state: 'clean' }
981
+ }
982
+
983
+ /**
984
+ * Create or reuse the blank rescue profile.
985
+ *
986
+ * The rescue is only allowed to start when it can be *proven* clean, so every
987
+ * refusal path returns `lines` for the caller to print instead of writing:
988
+ * - the home-level patch layer exists → it applies over every profile and
989
+ * this launcher cannot parse or compose it (see `homePatchFile`);
990
+ * - the profile directory exists but carries no valid root manifest → it may
991
+ * belong to something else, and `dsh plugin add` into it would mutate that;
992
+ * - the existing profile declares third-party plugins → starting it is not a
993
+ * clean start;
994
+ * - the profile's own patch layer carries entries, or the directory holds
995
+ * files this launcher did not generate → same reason, and removing them
996
+ * would destroy user data.
997
+ * @returns `{ kind: 'created' | 'exists' }`, or `{ kind: 'failed', lines }`.
998
+ */
999
+ const createRescueProfile = () => {
1000
+ if (existsSync(homePatchFile)) return { kind: 'failed', lines: [msg('safeRescueHomePatch')(homePatchFile)] }
1001
+ const state = rescueProfileState()
1002
+ if (state.state === 'unrecognized') return { kind: 'failed', lines: [msg('safeRescueUnrecognized')(rescueProfileDir)] }
1003
+ if (state.state === 'unclean') {
1004
+ return { kind: 'failed', lines: [msg('safeRescueUnclean')(rescueProfileDir, state.extras.join(', '))] }
1005
+ }
1006
+ if (state.state === 'patched') return { kind: 'failed', lines: [msg('safeRescuePatched')(rescuePatchFile)] }
1007
+ // 就绪判定与 bootstrapProfile 同源:安装判定文件在 node_modules 深处
1008
+ // (真实 dsh plugin add 与测试 stub 都落这里),不是 profile 根 manifest。
1009
+ if (state.state === 'clean') {
1010
+ if (readJson(rescueInstalledPkg) !== undefined) return { kind: 'exists' }
1011
+ // clean 但没装成:pnpm 会把半装状态视为「已是最新」,原地重试永远
1012
+ // no-op(与主 profile 的 bootstrapUnreadable 同一失败模式,issue #209)。
1013
+ // 清掉重建——但先确认目录里没有用户自己的东西(见 rescueDirStrays)。
1014
+ const stray = rescueDirStrays()
1015
+ if (stray.length > 0) return { kind: 'failed', lines: [msg('safeRescueStray')(rescueProfileDir, stray.join(', '))] }
1016
+ const failed = removeRescueDir()
1017
+ if (failed !== undefined) {
1018
+ return { kind: 'failed', lines: [msg('safeRescueRemoveFailed')(rescueProfileDir, failed.message)] }
1019
+ }
1020
+ console.log(msg('safeRescueCleanup')(rescueProfileDir))
1021
+ }
1022
+ const probe = spawnSync(...cmd('dsh', ['--version']), { stdio: 'pipe', ...shellOpt })
1023
+ if (probe.error || probe.status !== 0) return { kind: 'failed', lines: [msg('safeRescueFailed')('dsh missing')] }
1024
+ console.log(msg('safeRescueCreating'))
1025
+ const runAdd = extraArgs => spawnSync(
1026
+ ...cmd('dsh', ['plugin', '--profile', RESCUE_PROFILE, 'add', ...extraArgs, `${PACKAGE}@${installVersion}`]),
1027
+ { stdio: ['inherit', 'pipe', 'pipe'], env: rescueEnv(), ...shellOpt },
1028
+ )
1029
+ let add = runAdd([])
1030
+ if (add.status !== 0) {
1031
+ const captured = `${add.stdout ?? ''}${add.stderr ?? ''}`
1032
+ process.stderr.write(captured)
1033
+ if (captured.includes('ERR_PNPM_ADDING_TO_ROOT')) add = runAdd(['-w'])
1034
+ }
1035
+ if (add.status !== 0) return { kind: 'failed', lines: [msg('safeRescueFailed')(`exit ${add.status ?? 1}`)] }
1036
+ if (readJson(rescueInstalledPkg) === undefined) {
1037
+ // no-op 假成功:add 报成功但插件包仍不可读,pnpm 之后每次都「成功」而
1038
+ // 启动照旧失败——半成品会永久锁死选项 5。此时目录是本函数刚刚建出来的
1039
+ // (或刚通过 rescueDirStrays 检查后重建的),清掉并给出路径,让下一次
1040
+ // 尝试真的从零开始(bootstrapUnreadable 的同款处置)。
1041
+ const failed = removeRescueDir()
1042
+ const lines = [msg('safeRescueFailed')('no-op install')]
1043
+ lines.push(failed === undefined ? msg('safeRescueCleanup')(rescueProfileDir) : msg('safeRescueRemoveFailed')(rescueProfileDir, failed.message))
1044
+ return { kind: 'failed', lines }
1045
+ }
1046
+ return { kind: 'created' }
1047
+ }
1048
+
1049
+ // 首次启动的结算:signal 自杀透传;error 打印 launchFailed 后接 fallback
1050
+ // (TTY 询问进入安全模式,非 TTY 追加 safeHint)并以 1 收束;非零退出保留
1051
+ // profileExited 诊断、接 fallback 后按保真退出码收束。
1052
+ const settleFirstResult = async (result, firstArgs) => {
1053
+ if (result.kind === 'signal') {
1054
+ process.kill(process.pid, result.signal)
1055
+ return
1056
+ }
1057
+ if (result.kind === 'error') {
1058
+ console.error(msg('launchFailed')(result.error))
1059
+ if (isInteractive()) {
1060
+ if (await askSafeEntry(1)) process.exit(await runSafeSession({ pendingExitCode: 1, retryDsh: () => startDshSession(firstArgs) }))
1061
+ } else {
1062
+ console.error(msg('safeHint')(1))
1063
+ }
1064
+ process.exit(1)
1065
+ return
1066
+ }
1067
+ if (result.code !== 0) {
1068
+ console.error(msg('profileExited')(result.code))
1069
+ if (isInteractive()) {
1070
+ if (await askSafeEntry(result.code)) {
1071
+ process.exit(await runSafeSession({ pendingExitCode: result.code, retryDsh: () => startDshSession(firstArgs) }))
1072
+ }
1073
+ } else {
1074
+ console.error(msg('safeHint')(result.code))
1075
+ }
1076
+ }
1077
+ process.exit(result.code)
1078
+ }
1079
+
379
1080
  // 首次运行自举:探测 dsh 与 pnpm,随后 `dsh plugin add` 固定到与启动器一
380
1081
  // 致的版本(避免 pnpm store 缓存带来的旧版漂移)。-w 重试(issue #239)与
381
1082
  // 「no-op 假成功」复查(issue #209)在此集中实现,瘦壳与完整逻辑共用。
@@ -400,7 +1101,7 @@ const bootstrapProfile = () => {
400
1101
  }
401
1102
  console.log(msg('bootstrapStart'))
402
1103
  const runAdd = (extraArgs, capture) => spawnSync(
403
- ...cmd('dsh', ['plugin', '--profile', PROFILE, 'add', ...extraArgs, `${PACKAGE}@${ownVersion}`]),
1104
+ ...cmd('dsh', ['plugin', '--profile', PROFILE, 'add', ...extraArgs, `${PACKAGE}@${installVersion}`]),
404
1105
  { stdio: capture ? ['inherit', 'pipe', 'pipe'] : 'inherit', ...shellOpt },
405
1106
  )
406
1107
  let add = runAdd([], true)
@@ -473,6 +1174,35 @@ const checkProfileAlignment = installedVersion => {
473
1174
  }
474
1175
  }
475
1176
 
1177
+ // ─── 子命令:safe(安全模式入口,两种角色同一段代码)──────────────────────────
1178
+ // 零 lib 依赖、不委托、不自举(对齐 doctor 的依赖边界,而非 update 的
1179
+ // profile-lib 路径):profile 损坏时它必须仍可达。控制面只读;重试与
1180
+ // 修复动作语义见 safe 会话实现(spec §4/§5)。
1181
+ // `safe --rescue` 是同一个救援动作的显式入口(不是新动作):交互终端里
1182
+ // 等价于菜单选项 5(门禁 → 创建/复用 → 干净启动),非交互终端里只做
1183
+ // 门禁 + 创建/复用并报告结论(没有终端可交接时不启动 TUI),以便脚本与
1184
+ // 无头环境也能用上救援通道、并且**有非零退出码**可判。其余附加参数照旧
1185
+ // 只提示忽略。
1186
+ if (subcommand === 'safe') {
1187
+ const extra = process.argv.slice(3)
1188
+ const rescueOnly = extra.includes('--rescue')
1189
+ const ignored = extra.filter(a => a !== '--rescue')
1190
+ const extraLines = ignored.length > 0 ? [msg('safeIgnoredArgs')(ignored.length)] : []
1191
+ if (!isInteractive()) {
1192
+ for (const line of renderSafeReport(extraLines)) console.log(line)
1193
+ if (!rescueOnly) process.exit(0)
1194
+ const created = createRescueProfile()
1195
+ if (created.kind !== 'created' && created.kind !== 'exists') {
1196
+ for (const l of created.lines) console.error(l)
1197
+ process.exit(1)
1198
+ }
1199
+ // 结论行走非交互专用键:这条路径到此为止,没有 startDshSession。
1200
+ console.log(created.kind === 'exists' ? msg('safeRescueExistsNonInteractive') : msg('safeRescueCreatedNonInteractive'))
1201
+ process.exit(0)
1202
+ }
1203
+ process.exit(await runSafeSession({ pendingExitCode: 0, retryDsh: null, extraLines, rescueFirst: rescueOnly }))
1204
+ }
1205
+
476
1206
  // ─── 子命令:update ──────────────────────────────────────────────────────────
477
1207
  // 顶层处理、两种角色同一条路径——不放进委托链。委托会把 update 交给
478
1208
  // profile 内的旧 bin:旧副本不认识这个词,只会当参数透传,恰好是「profile
@@ -601,10 +1331,6 @@ if (!runningInsideProfile && ownVersion !== undefined && process.env.DSH_TUI_NO_
601
1331
  process.env.DSH_TUI_LAUNCHER_VERSION = ownVersion
602
1332
  }
603
1333
 
604
- const child = spawn(...cmd('dsh', ['--profile', PROFILE, ...args]), {
605
- stdio: 'inherit',
606
- env: process.env,
607
- ...shellOpt,
608
- })
609
- forwardExit(child)
1334
+ const firstArgs = args
1335
+ settleFirstResult(await startDshSession(firstArgs), firstArgs)
610
1336
  }