@deepseek-harness-tui/dsh-tui 0.6.1 → 0.7.1

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 (380) hide show
  1. package/README.md +77 -23
  2. package/bin/dsh-tui.js +132 -25
  3. package/cordis.patch.yml +69 -17
  4. package/cordis.yml +50 -12
  5. package/lib/types/activityPrefs.d.ts +1 -1
  6. package/lib/types/activityPrefs.js +3 -3
  7. package/lib/types/cc/figures.d.ts +27 -20
  8. package/lib/types/cc/figures.d.ts.map +1 -1
  9. package/lib/types/cc/figures.js +43 -34
  10. package/lib/types/cc/format.d.ts +18 -9
  11. package/lib/types/cc/format.d.ts.map +1 -1
  12. package/lib/types/cc/format.js +26 -18
  13. package/lib/types/cc/markdown.d.ts +31 -16
  14. package/lib/types/cc/markdown.d.ts.map +1 -1
  15. package/lib/types/cc/markdown.js +283 -260
  16. package/lib/types/cc/terminal.d.ts +10 -7
  17. package/lib/types/cc/terminal.d.ts.map +1 -1
  18. package/lib/types/cc/terminal.js +62 -50
  19. package/lib/types/command-trees.d.ts +3 -0
  20. package/lib/types/command-trees.d.ts.map +1 -0
  21. package/lib/types/command-trees.js +4 -0
  22. package/lib/types/commands.d.ts +37 -1
  23. package/lib/types/commands.d.ts.map +1 -1
  24. package/lib/types/commands.js +68 -3
  25. package/lib/types/components/ActivityLine.d.ts +1 -1
  26. package/lib/types/components/ActivityLine.d.ts.map +1 -1
  27. package/lib/types/components/ActivityPicker.d.ts.map +1 -1
  28. package/lib/types/components/ActivityPicker.js +3 -4
  29. package/lib/types/components/BtwPanel.d.ts +16 -0
  30. package/lib/types/components/BtwPanel.d.ts.map +1 -0
  31. package/lib/types/components/BtwPanel.js +47 -0
  32. package/lib/types/components/CommandSuggestions.d.ts +4 -2
  33. package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
  34. package/lib/types/components/CommandSuggestions.js +2 -2
  35. package/lib/types/components/EffortSlider.d.ts +15 -0
  36. package/lib/types/components/EffortSlider.d.ts.map +1 -0
  37. package/lib/types/components/EffortSlider.js +17 -0
  38. package/lib/types/components/GoalTodoPanel.d.ts +1 -1
  39. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  40. package/lib/types/components/HelpMenu.d.ts +5 -2
  41. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  42. package/lib/types/components/HelpMenu.js +8 -3
  43. package/lib/types/components/HistorySearchDialog.d.ts +1 -1
  44. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  45. package/lib/types/components/HistorySearchDialog.js +21 -10
  46. package/lib/types/components/InterruptedByUser.d.ts +2 -2
  47. package/lib/types/components/InterruptedByUser.d.ts.map +1 -1
  48. package/lib/types/components/InterruptedByUser.js +4 -3
  49. package/lib/types/components/LoadedContextPanel.d.ts +1 -1
  50. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  51. package/lib/types/components/LogoV2.d.ts.map +1 -1
  52. package/lib/types/components/LogoV2.js +2 -1
  53. package/lib/types/components/Markdown.d.ts +11 -11
  54. package/lib/types/components/Markdown.d.ts.map +1 -1
  55. package/lib/types/components/Markdown.js +63 -57
  56. package/lib/types/components/MarkdownTable.d.ts +1 -1
  57. package/lib/types/components/MarkdownTable.js +2 -2
  58. package/lib/types/components/MessageList.d.ts +10 -2
  59. package/lib/types/components/MessageList.d.ts.map +1 -1
  60. package/lib/types/components/MessageList.js +17 -10
  61. package/lib/types/components/ModelPicker.d.ts +4 -1
  62. package/lib/types/components/ModelPicker.d.ts.map +1 -1
  63. package/lib/types/components/ModelPicker.js +16 -4
  64. package/lib/types/components/OverlayAbove.d.ts +20 -0
  65. package/lib/types/components/OverlayAbove.d.ts.map +1 -0
  66. package/lib/types/components/OverlayAbove.js +18 -0
  67. package/lib/types/components/PresetPicker.d.ts +1 -1
  68. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  69. package/lib/types/components/PresetPicker.js +2 -3
  70. package/lib/types/components/PromptInput.d.ts +5 -4
  71. package/lib/types/components/PromptInput.d.ts.map +1 -1
  72. package/lib/types/components/PromptInput.js +186 -39
  73. package/lib/types/components/ResumePicker.d.ts +15 -4
  74. package/lib/types/components/ResumePicker.d.ts.map +1 -1
  75. package/lib/types/components/ResumePicker.js +25 -9
  76. package/lib/types/components/RewindPicker.d.ts +1 -1
  77. package/lib/types/components/RewindPicker.d.ts.map +1 -1
  78. package/lib/types/components/RewindPicker.js +14 -5
  79. package/lib/types/components/SearchBox.d.ts +1 -1
  80. package/lib/types/components/SearchBox.js +1 -1
  81. package/lib/types/components/Spinner/GlimmerMessage.d.ts +1 -2
  82. package/lib/types/components/Spinner/GlimmerMessage.d.ts.map +1 -1
  83. package/lib/types/components/Spinner/GlimmerMessage.js +1 -2
  84. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts +1 -1
  85. package/lib/types/components/Spinner/SpinnerAnimationRow.js +2 -2
  86. package/lib/types/components/Spinner/SpinnerGlyph.d.ts +1 -1
  87. package/lib/types/components/Spinner/SpinnerGlyph.js +1 -1
  88. package/lib/types/components/Spinner/spinnerUtils.d.ts +13 -18
  89. package/lib/types/components/Spinner/spinnerUtils.d.ts.map +1 -1
  90. package/lib/types/components/Spinner/spinnerUtils.js +67 -71
  91. package/lib/types/components/Spinner/useStalledAnimation.d.ts +1 -2
  92. package/lib/types/components/Spinner/useStalledAnimation.d.ts.map +1 -1
  93. package/lib/types/components/Spinner/useStalledAnimation.js +1 -2
  94. package/lib/types/components/StreamingMarkdown.d.ts +1 -1
  95. package/lib/types/components/StreamingMarkdown.js +1 -1
  96. package/lib/types/components/ThemePicker.d.ts +13 -9
  97. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  98. package/lib/types/components/ThemePicker.js +21 -15
  99. package/lib/types/components/ThinkingToggle.d.ts +1 -1
  100. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  101. package/lib/types/components/ThinkingToggle.js +8 -8
  102. package/lib/types/components/ToolUseLoader.d.ts +1 -1
  103. package/lib/types/components/ToolUseLoader.js +1 -1
  104. package/lib/types/components/WorkingSpinner.d.ts +2 -2
  105. package/lib/types/components/WorkingSpinner.js +2 -2
  106. package/lib/types/components/WorkspaceFlowPicker.d.ts +15 -0
  107. package/lib/types/components/WorkspaceFlowPicker.d.ts.map +1 -0
  108. package/lib/types/components/WorkspaceFlowPicker.js +19 -0
  109. package/lib/types/components/WorkspacePicker.d.ts +9 -0
  110. package/lib/types/components/WorkspacePicker.d.ts.map +1 -0
  111. package/lib/types/components/WorkspacePicker.js +13 -0
  112. package/lib/types/components/approvals/ApprovalPanel.d.ts +1 -1
  113. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  114. package/lib/types/components/approvals/ApprovalPanel.js +2 -1
  115. package/lib/types/components/design-system/Byline.d.ts +1 -1
  116. package/lib/types/components/design-system/Byline.js +1 -1
  117. package/lib/types/components/design-system/Divider.d.ts +1 -1
  118. package/lib/types/components/design-system/Divider.js +1 -1
  119. package/lib/types/components/design-system/HintLine.d.ts +12 -0
  120. package/lib/types/components/design-system/HintLine.d.ts.map +1 -0
  121. package/lib/types/components/design-system/HintLine.js +15 -0
  122. package/lib/types/components/design-system/KeyboardShortcutHint.d.ts +1 -1
  123. package/lib/types/components/design-system/KeyboardShortcutHint.js +1 -1
  124. package/lib/types/components/design-system/ListItem.d.ts +2 -2
  125. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  126. package/lib/types/components/design-system/ListItem.js +35 -3
  127. package/lib/types/components/design-system/LoadingState.d.ts +1 -2
  128. package/lib/types/components/design-system/LoadingState.d.ts.map +1 -1
  129. package/lib/types/components/design-system/LoadingState.js +1 -2
  130. package/lib/types/components/design-system/Pane.d.ts +2 -2
  131. package/lib/types/components/design-system/Pane.js +2 -2
  132. package/lib/types/components/design-system/ProgressBar.d.ts +1 -2
  133. package/lib/types/components/design-system/ProgressBar.d.ts.map +1 -1
  134. package/lib/types/components/design-system/ProgressBar.js +1 -2
  135. package/lib/types/components/design-system/StatusIcon.d.ts +2 -2
  136. package/lib/types/components/design-system/StatusIcon.js +2 -2
  137. package/lib/types/components/design-system/ThemeProvider.d.ts +1 -1
  138. package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
  139. package/lib/types/components/design-system/ThemeProvider.js +87 -26
  140. package/lib/types/components/design-system/ThemedBox.d.ts +1 -1
  141. package/lib/types/components/design-system/ThemedBox.js +1 -1
  142. package/lib/types/components/design-system/ThemedText.d.ts +2 -2
  143. package/lib/types/components/design-system/ThemedText.js +2 -2
  144. package/lib/types/components/listWindow.d.ts +26 -0
  145. package/lib/types/components/listWindow.d.ts.map +1 -0
  146. package/lib/types/components/listWindow.js +47 -0
  147. package/lib/types/components/messages/AssistantTextMessage.d.ts +1 -2
  148. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -1
  149. package/lib/types/components/messages/AssistantTextMessage.js +1 -2
  150. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -2
  151. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  152. package/lib/types/components/messages/AssistantThinkingMessage.js +6 -6
  153. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +12 -4
  154. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  155. package/lib/types/components/messages/AssistantToolUseMessage.js +9 -5
  156. package/lib/types/components/messages/MessageMetadata.d.ts +1 -1
  157. package/lib/types/components/messages/MessageMetadata.js +1 -1
  158. package/lib/types/components/messages/UserPromptMessage.d.ts +1 -1
  159. package/lib/types/components/messages/UserPromptMessage.js +1 -1
  160. package/lib/types/components/questions/AskUserQuestionPanel.d.ts +5 -1
  161. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  162. package/lib/types/components/questions/AskUserQuestionPanel.js +11 -9
  163. package/lib/types/components/questions/PlanReviewPanel.d.ts +1 -1
  164. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  165. package/lib/types/components/questions/PlanReviewPanel.js +3 -2
  166. package/lib/types/components/trajectory/HotspotView.d.ts +14 -0
  167. package/lib/types/components/trajectory/HotspotView.d.ts.map +1 -0
  168. package/lib/types/components/trajectory/HotspotView.js +83 -0
  169. package/lib/types/components/trajectory/Inspector.d.ts +31 -0
  170. package/lib/types/components/trajectory/Inspector.d.ts.map +1 -0
  171. package/lib/types/components/trajectory/Inspector.js +67 -0
  172. package/lib/types/components/trajectory/Ledger.d.ts +20 -0
  173. package/lib/types/components/trajectory/Ledger.d.ts.map +1 -0
  174. package/lib/types/components/trajectory/Ledger.js +112 -0
  175. package/lib/types/components/trajectory/MiniWake.d.ts +24 -0
  176. package/lib/types/components/trajectory/MiniWake.d.ts.map +1 -0
  177. package/lib/types/components/trajectory/MiniWake.js +99 -0
  178. package/lib/types/components/trajectory/WaveBand.d.ts +33 -0
  179. package/lib/types/components/trajectory/WaveBand.d.ts.map +1 -0
  180. package/lib/types/components/trajectory/WaveBand.js +145 -0
  181. package/lib/types/customTheme.d.ts +6 -6
  182. package/lib/types/customTheme.d.ts.map +1 -1
  183. package/lib/types/customTheme.js +15 -12
  184. package/lib/types/dsh-adapter/approvals.d.ts.map +1 -0
  185. package/lib/types/{channel.d.ts → dsh-adapter/channel.d.ts} +198 -43
  186. package/lib/types/dsh-adapter/channel.d.ts.map +1 -0
  187. package/lib/types/{channel.js → dsh-adapter/channel.js} +1129 -201
  188. package/lib/types/dsh-adapter/childStderr.d.ts.map +1 -0
  189. package/lib/types/{childStderr.js → dsh-adapter/childStderr.js} +1 -1
  190. package/lib/types/dsh-adapter/command-trees.d.ts +27 -0
  191. package/lib/types/dsh-adapter/command-trees.d.ts.map +1 -0
  192. package/lib/types/dsh-adapter/command-trees.js +42 -0
  193. package/lib/types/dsh-adapter/compat/index.d.ts +22 -0
  194. package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -0
  195. package/lib/types/{compat → dsh-adapter/compat}/index.js +7 -4
  196. package/lib/types/dsh-adapter/compat/sessionLog.d.ts +86 -0
  197. package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -0
  198. package/lib/types/dsh-adapter/compat/sessionLog.js +347 -0
  199. package/lib/types/dsh-adapter/contract.d.ts +20 -0
  200. package/lib/types/dsh-adapter/contract.d.ts.map +1 -0
  201. package/lib/types/dsh-adapter/contract.js +109 -0
  202. package/lib/types/dsh-adapter/index.d.ts +80 -0
  203. package/lib/types/dsh-adapter/index.d.ts.map +1 -0
  204. package/lib/types/dsh-adapter/index.js +45 -0
  205. package/lib/types/dsh-adapter/invariant.d.ts.map +1 -0
  206. package/lib/types/dsh-adapter/packaged-skills.d.ts.map +1 -0
  207. package/lib/types/{plugin.d.ts → dsh-adapter/plugin.d.ts} +17 -0
  208. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -0
  209. package/lib/types/{plugin.js → dsh-adapter/plugin.js} +294 -80
  210. package/lib/types/dsh-adapter/presets.d.ts.map +1 -0
  211. package/lib/types/dsh-adapter/providerWizard.d.ts +91 -0
  212. package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -0
  213. package/lib/types/dsh-adapter/providerWizard.js +332 -0
  214. package/lib/types/{questions.d.ts → dsh-adapter/questions.d.ts} +7 -2
  215. package/lib/types/dsh-adapter/questions.d.ts.map +1 -0
  216. package/lib/types/{questions.js → dsh-adapter/questions.js} +15 -7
  217. package/lib/types/dsh-adapter/sideQuestion.d.ts +38 -0
  218. package/lib/types/dsh-adapter/sideQuestion.d.ts.map +1 -0
  219. package/lib/types/dsh-adapter/sideQuestion.js +69 -0
  220. package/lib/types/dsh-adapter/trajectory/aggregate.d.ts +46 -0
  221. package/lib/types/dsh-adapter/trajectory/aggregate.d.ts.map +1 -0
  222. package/lib/types/dsh-adapter/trajectory/aggregate.js +207 -0
  223. package/lib/types/dsh-adapter/trajectory/guards.d.ts +177 -0
  224. package/lib/types/dsh-adapter/trajectory/guards.d.ts.map +1 -0
  225. package/lib/types/dsh-adapter/trajectory/guards.js +235 -0
  226. package/lib/types/dsh-adapter/trajectory/index.d.ts +16 -0
  227. package/lib/types/dsh-adapter/trajectory/index.d.ts.map +1 -0
  228. package/lib/types/dsh-adapter/trajectory/index.js +15 -0
  229. package/lib/types/dsh-adapter/trajectory/inspect.d.ts +40 -0
  230. package/lib/types/dsh-adapter/trajectory/inspect.d.ts.map +1 -0
  231. package/lib/types/dsh-adapter/trajectory/inspect.js +160 -0
  232. package/lib/types/dsh-adapter/trajectory/projection.d.ts +128 -0
  233. package/lib/types/dsh-adapter/trajectory/projection.d.ts.map +1 -0
  234. package/lib/types/dsh-adapter/trajectory/projection.js +653 -0
  235. package/lib/types/dsh-adapter/trajectory/types.d.ts +232 -0
  236. package/lib/types/dsh-adapter/trajectory/types.d.ts.map +1 -0
  237. package/lib/types/dsh-adapter/trajectory/types.js +70 -0
  238. package/lib/types/dsh-adapter/trajectory/wave.d.ts +45 -0
  239. package/lib/types/dsh-adapter/trajectory/wave.d.ts.map +1 -0
  240. package/lib/types/dsh-adapter/trajectory/wave.js +210 -0
  241. package/lib/types/dsh-adapter/types.d.ts +23 -0
  242. package/lib/types/dsh-adapter/types.d.ts.map +1 -0
  243. package/lib/types/dsh-adapter/types.js +1 -0
  244. package/lib/types/dsh-adapter/workspace.d.ts +22 -0
  245. package/lib/types/dsh-adapter/workspace.d.ts.map +1 -0
  246. package/lib/types/dsh-adapter/workspace.js +16 -0
  247. package/lib/types/dsh-adapter/workspaces.d.ts +118 -0
  248. package/lib/types/dsh-adapter/workspaces.d.ts.map +1 -0
  249. package/lib/types/dsh-adapter/workspaces.js +290 -0
  250. package/lib/types/effortPrefs.d.ts +1 -1
  251. package/lib/types/effortPrefs.js +3 -3
  252. package/lib/types/history.js +2 -2
  253. package/lib/types/hooks/useBlink.d.ts +1 -1
  254. package/lib/types/hooks/useBlink.js +1 -1
  255. package/lib/types/i18n.d.ts +981 -36
  256. package/lib/types/i18n.d.ts.map +1 -1
  257. package/lib/types/i18n.js +308 -30
  258. package/lib/types/index.d.ts +1 -69
  259. package/lib/types/index.d.ts.map +1 -1
  260. package/lib/types/index.js +3 -31
  261. package/lib/types/ink/components/App.d.ts.map +1 -1
  262. package/lib/types/ink/components/App.js +33 -19
  263. package/lib/types/ink/dom.d.ts +15 -0
  264. package/lib/types/ink/dom.d.ts.map +1 -1
  265. package/lib/types/ink/dom.js +32 -0
  266. package/lib/types/ink/hooks/use-input.d.ts.map +1 -1
  267. package/lib/types/ink/hooks/use-input.js +8 -0
  268. package/lib/types/ink/hooks/use-terminal-size.d.ts +1 -1
  269. package/lib/types/ink/hooks/use-terminal-size.js +1 -1
  270. package/lib/types/ink/ink.d.ts.map +1 -1
  271. package/lib/types/ink/ink.js +109 -28
  272. package/lib/types/ink/input-suppression.d.ts +20 -0
  273. package/lib/types/ink/input-suppression.d.ts.map +1 -0
  274. package/lib/types/ink/input-suppression.js +26 -0
  275. package/lib/types/ink/log-update.js +21 -6
  276. package/lib/types/ink/parse-keypress.d.ts +31 -0
  277. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  278. package/lib/types/ink/parse-keypress.js +442 -8
  279. package/lib/types/ink/terminal-querier.d.ts +5 -1
  280. package/lib/types/ink/terminal-querier.d.ts.map +1 -1
  281. package/lib/types/ink/terminal-querier.js +27 -3
  282. package/lib/types/ink/terminal.d.ts +18 -0
  283. package/lib/types/ink/terminal.d.ts.map +1 -1
  284. package/lib/types/ink/terminal.js +32 -4
  285. package/lib/types/ink/termio/csi.d.ts +10 -0
  286. package/lib/types/ink/termio/csi.d.ts.map +1 -1
  287. package/lib/types/ink/termio/csi.js +20 -0
  288. package/lib/types/ink/termio/tokenize.js +10 -0
  289. package/lib/types/modelPrefs.d.ts +1 -1
  290. package/lib/types/modelPrefs.js +3 -3
  291. package/lib/types/presetPrefs.d.ts +1 -1
  292. package/lib/types/presetPrefs.js +3 -3
  293. package/lib/types/screens/Chat.d.ts +20 -4
  294. package/lib/types/screens/Chat.d.ts.map +1 -1
  295. package/lib/types/screens/Chat.js +655 -55
  296. package/lib/types/screens/StatusLine.d.ts +17 -2
  297. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  298. package/lib/types/screens/StatusLine.js +12 -4
  299. package/lib/types/screens/TrajectoryScene.d.ts +16 -0
  300. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -0
  301. package/lib/types/screens/TrajectoryScene.js +305 -0
  302. package/lib/types/sessionHistory.d.ts +9 -1
  303. package/lib/types/sessionHistory.d.ts.map +1 -1
  304. package/lib/types/sessionHistory.js +56 -16
  305. package/lib/types/sessionModes.d.ts +29 -0
  306. package/lib/types/sessionModes.d.ts.map +1 -0
  307. package/lib/types/sessionModes.js +45 -0
  308. package/lib/types/theme.d.ts +27 -3
  309. package/lib/types/theme.d.ts.map +1 -1
  310. package/lib/types/theme.js +48 -4
  311. package/lib/types/themePrefs.d.ts +3 -3
  312. package/lib/types/themePrefs.js +5 -5
  313. package/lib/types/trajectory/format.d.ts +100 -0
  314. package/lib/types/trajectory/format.d.ts.map +1 -0
  315. package/lib/types/trajectory/format.js +213 -0
  316. package/lib/types/trajectory/motion.d.ts +99 -0
  317. package/lib/types/trajectory/motion.d.ts.map +1 -0
  318. package/lib/types/trajectory/motion.js +143 -0
  319. package/lib/types/trajectory/query.d.ts +83 -0
  320. package/lib/types/trajectory/query.d.ts.map +1 -0
  321. package/lib/types/trajectory/query.js +187 -0
  322. package/lib/types/trajectoryPrefs.d.ts +36 -0
  323. package/lib/types/trajectoryPrefs.d.ts.map +1 -0
  324. package/lib/types/trajectoryPrefs.js +65 -0
  325. package/lib/types/update.d.ts +3 -3
  326. package/lib/types/update.d.ts.map +1 -1
  327. package/lib/types/update.js +18 -7
  328. package/lib/types/utils/clipboard.d.ts +101 -29
  329. package/lib/types/utils/clipboard.d.ts.map +1 -1
  330. package/lib/types/utils/clipboard.js +533 -55
  331. package/lib/types/utils/debug.d.ts +1 -1
  332. package/lib/types/utils/debug.js +2 -2
  333. package/lib/types/utils/execFileNoThrow.d.ts.map +1 -1
  334. package/lib/types/utils/execFileNoThrow.js +16 -6
  335. package/lib/types/utils/externalEditor.d.ts +115 -0
  336. package/lib/types/utils/externalEditor.d.ts.map +1 -0
  337. package/lib/types/utils/externalEditor.js +270 -0
  338. package/lib/types/utils/fullscreen.d.ts +2 -2
  339. package/lib/types/utils/fullscreen.js +3 -3
  340. package/lib/types/utils/loaded-context.d.ts +1 -1
  341. package/lib/types/utils/loaded-context.d.ts.map +1 -1
  342. package/lib/types/utils/modifiers.d.ts +24 -0
  343. package/lib/types/utils/modifiers.d.ts.map +1 -1
  344. package/lib/types/utils/modifiers.js +27 -0
  345. package/lib/types/utils/paths.d.ts +45 -0
  346. package/lib/types/utils/paths.d.ts.map +1 -0
  347. package/lib/types/utils/paths.js +69 -0
  348. package/lib/types/utils/shellQuote.d.ts +14 -0
  349. package/lib/types/utils/shellQuote.d.ts.map +1 -0
  350. package/lib/types/utils/shellQuote.js +39 -0
  351. package/lib/types/utils/workspaceRoot.d.ts +21 -0
  352. package/lib/types/utils/workspaceRoot.d.ts.map +1 -0
  353. package/lib/types/utils/workspaceRoot.js +47 -0
  354. package/lib/types/workspaces.d.ts +3 -0
  355. package/lib/types/workspaces.d.ts.map +1 -0
  356. package/lib/types/workspaces.js +4 -0
  357. package/package.json +31 -10
  358. package/lib/invariant.js +0 -28
  359. package/lib/types/approvals.d.ts.map +0 -1
  360. package/lib/types/channel.d.ts.map +0 -1
  361. package/lib/types/childStderr.d.ts.map +0 -1
  362. package/lib/types/compat/index.d.ts +0 -20
  363. package/lib/types/compat/index.d.ts.map +0 -1
  364. package/lib/types/compat/sessionLog.d.ts +0 -40
  365. package/lib/types/compat/sessionLog.d.ts.map +0 -1
  366. package/lib/types/compat/sessionLog.js +0 -226
  367. package/lib/types/invariant.d.ts.map +0 -1
  368. package/lib/types/packaged-skills.d.ts.map +0 -1
  369. package/lib/types/plugin.d.ts.map +0 -1
  370. package/lib/types/presets.d.ts.map +0 -1
  371. package/lib/types/questions.d.ts.map +0 -1
  372. /package/lib/types/{approvals.d.ts → dsh-adapter/approvals.d.ts} +0 -0
  373. /package/lib/types/{approvals.js → dsh-adapter/approvals.js} +0 -0
  374. /package/lib/types/{childStderr.d.ts → dsh-adapter/childStderr.d.ts} +0 -0
  375. /package/lib/types/{invariant.d.ts → dsh-adapter/invariant.d.ts} +0 -0
  376. /package/lib/types/{invariant.js → dsh-adapter/invariant.js} +0 -0
  377. /package/lib/types/{packaged-skills.d.ts → dsh-adapter/packaged-skills.d.ts} +0 -0
  378. /package/lib/types/{packaged-skills.js → dsh-adapter/packaged-skills.js} +0 -0
  379. /package/lib/types/{presets.d.ts → dsh-adapter/presets.d.ts} +0 -0
  380. /package/lib/types/{presets.js → dsh-adapter/presets.js} +0 -0
@@ -1,25 +1,36 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agent';
3
+ import { isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill';
3
4
  import { createUserMessage, isTokenDelta, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
5
+ import { runSideQuestion, wrapSideQuestion } from './sideQuestion.js';
4
6
  import { SessionId } from '@deepseek-ai/dsh-session';
5
7
  import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
6
- import { discoverBaselineInstructionFiles } from '@deepseek-ai/dsh-agent-instructions';
7
- import { isAbsolute, join } from 'node:path';
8
- import { LOCAL_COMMANDS } from './commands.js';
9
- import { clearResumeTarget, readLastUsed, touchSession, writeResumeTarget } from './sessionHistory.js';
10
- import { prepareSessionForResume, readSessionTitleFromLog } from './compat/index.js';
11
- import { writeActivityFrames } from './activityPrefs.js';
12
- import { readEffortPref, writeEffortPref } from './effortPrefs.js';
13
- import { readModelPref, writeModelPref } from './modelPrefs.js';
14
- import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from './modelRoute.js';
15
- import { readPresetPref, writePresetPref } from './presetPrefs.js';
8
+ import { loadBaselineInstructions } from '@deepseek-ai/dsh-agent-instructions';
9
+ import { extname, isAbsolute, join } from 'node:path';
10
+ import { completeCommands, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
11
+ import { clearResumeTarget, forgetSession, readLastUsed, readResumeTarget, touchSession, writeResumeTarget } from '../sessionHistory.js';
12
+ import { appendSessionTitle, deleteSessionLog, ensureLegacySessionEventTypes, readSessionTitleFromLog, sessionsRoots } from './compat/index.js';
13
+ import { writeActivityFrames } from '../activityPrefs.js';
14
+ import { readEffortPref, writeEffortPref } from '../effortPrefs.js';
15
+ import { readModelPref, writeModelPref } from '../modelPrefs.js';
16
+ import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from '../modelRoute.js';
17
+ import { readPresetPref, writePresetPref } from '../presetPrefs.js';
16
18
  import { composePreset, resolvePersistedPreset, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
17
- import { isPresetName } from './components/activityFrames.js';
18
- import { existsSync, writeFileSync } from 'node:fs';
19
- import { homedir } from 'node:os';
20
- import { logForDebugging } from './utils/debug.js';
21
- import { extractMentions } from './utils/mentions.js';
22
- import { t } from './i18n.js';
19
+ import { isPresetName } from '../components/activityFrames.js';
20
+ import { existsSync, statSync, writeFileSync } from 'node:fs';
21
+ import { logForDebugging } from '../utils/debug.js';
22
+ import { homeDir, LEGACY_DATA_DIR } from '../utils/paths.js';
23
+ import { extractMentions } from '../utils/mentions.js';
24
+ import { t } from '../i18n.js';
25
+ import { modeDisplayName, resolveSessionModes } from '../sessionModes.js';
26
+ import { ActivityTracker } from 'dsh-working-activity/status';
27
+ import { attachSessionToWorkspace } from './workspace.js';
28
+ import { createLocalWorkspaceRuntime } from './workspaces.js';
29
+ /**
30
+ * Delay before re-reading a skill catalog that reported an incomplete
31
+ * observation (a provider whose directory watcher is still warming).
32
+ */
33
+ const SKILL_COMMAND_RETRY_MS = 800;
23
34
  const ARGS_PREVIEW_LIMIT = 160;
24
35
  const RESULT_PREVIEW_LIMIT = 240;
25
36
  /** Local `!`-command output cap (mirrors the result preview limit). */
@@ -172,6 +183,21 @@ function restoreRowFromEvent(row, event) {
172
183
  break;
173
184
  }
174
185
  }
186
+ /** Render the durable tool-result payload, including provider error details. */
187
+ function toolResultText(event) {
188
+ const block = event.data.message.content[0];
189
+ if (block === undefined || block.type !== 'tool-result')
190
+ return '';
191
+ return block.content.map(item => item.type === 'text' ? item.text : '').join('').trim();
192
+ }
193
+ function toolErrorText(event) {
194
+ const failure = event.data.error;
195
+ if (failure === undefined)
196
+ return '';
197
+ const identity = `${failure.name}: ${failure.code}`;
198
+ const detail = toolResultText(event);
199
+ return detail === '' || detail === identity ? identity : `${identity} — ${detail}`;
200
+ }
175
201
  /** Restore a folded tool row's result text from its tool/result event. */
176
202
  function restoreToolResult(row, event) {
177
203
  if (row.tool === undefined)
@@ -179,12 +205,11 @@ function restoreToolResult(row, event) {
179
205
  const failure = event.data.error;
180
206
  if (failure !== undefined) {
181
207
  row.tool.status = 'error';
182
- row.tool.errorText = `${failure.name}: ${failure.code}`;
208
+ row.tool.errorText = toolErrorText(event);
183
209
  return;
184
210
  }
185
211
  row.tool.status = 'ok';
186
- const block = event.data.message.content[0];
187
- const result = block.content.map(b => b.type === 'text' ? b.text : '').join('').trim();
212
+ const result = toolResultText(event);
188
213
  row.tool.resultFull = result || undefined;
189
214
  }
190
215
  /**
@@ -278,6 +303,14 @@ export function createChannel(ctx, initialAgent, options) {
278
303
  // command/run + command/done records). Absent the service, only the
279
304
  // built-in local commands exist.
280
305
  const commandService = ctx.get('commands');
306
+ const workspaceService = ctx.tuiWorkspaces ?? createLocalWorkspaceRuntime();
307
+ const commandTrees = ctx.get('tuiCommandTrees');
308
+ // Shift+Tab session-mode cycle: cordis.yml `modes` wins; absent/empty/
309
+ // atom-less → the built-in default/plan/full cycle (sessionModes.ts).
310
+ const { modes: sessionModes, dropped: droppedModeIds } = resolveSessionModes(options.modes);
311
+ if (droppedModeIds.length > 0) {
312
+ ctx.logger.warn(`dsh-tui: session modes ${droppedModeIds.map(id => `"${id}"`).join(', ')} declare no plan/sandbox/approval atom; dropped from the Shift+Tab cycle`);
313
+ }
281
314
  const listeners = new Set();
282
315
  /** True while a frame-aligned stream notification is pending (emitStream). */
283
316
  let streamNotifyScheduled = false;
@@ -292,7 +325,7 @@ export function createChannel(ctx, initialAgent, options) {
292
325
  return;
293
326
  contextWarned = true;
294
327
  const percentLeft = Math.max(0, Math.round((remaining / state.contextWindow) * 100));
295
- state.notify(`Context low (${percentLeft}% remaining) · Run /clear or start a new session`, { color: 'warning', timeoutMs: 8000 });
328
+ state.notify(t('context-low-warning', { percent: percentLeft }), { color: 'warning', timeoutMs: 8000 });
296
329
  };
297
330
  /**
298
331
  * Register a submitted message as pending and notify the UI. The inbox
@@ -316,6 +349,12 @@ export function createChannel(ctx, initialAgent, options) {
316
349
  * through this chain to keep the send order FIFO.
317
350
  */
318
351
  let sendChain = Promise.resolve();
352
+ let stagedImageSequence = 0;
353
+ const stagedImages = new Map();
354
+ const clearStagedImages = () => {
355
+ stagedImages.clear();
356
+ stagedImageSequence = 0;
357
+ };
319
358
  /**
320
359
  * Expand the text's `@` mentions and deliver ONE user message: the typed
321
360
  * text stays the first content block (the transcript bubble renders it —
@@ -324,7 +363,7 @@ export function createChannel(ctx, initialAgent, options) {
324
363
  */
325
364
  const deliverUserText = (text, placement) => {
326
365
  sendChain = sendChain.then(async () => {
327
- const expansion = await expandMentions(mentionFs(ctx), state.cwd, text);
366
+ const expansion = await expandMentions(mentionFs(ctx), state.cwd, text, mentionAttachments(ctx), stagedImages);
328
367
  const message = createUserMessage({
329
368
  content: expansion.blocks,
330
369
  source: { kind: 'user' },
@@ -393,51 +432,191 @@ export function createChannel(ctx, initialAgent, options) {
393
432
  // provider default in effect.
394
433
  }
395
434
  };
396
- /** Shift+Tab: cycle the live route's adapter-owned reasoning efforts in
397
- * the adapter's own display order (dsh parity — deepseek: Off→High→Max).
398
- * The choice persists to ~/.dsh-cc/effort.json and follows future agents
399
- * on this channel (resume/model switch re-validate it per route). */
400
- const cycleEffort = async () => {
401
- if (llmRuntime === undefined) {
402
- state.notify(t('effort-unavailable'), { color: 'error' });
403
- return;
404
- }
405
- let efforts;
406
- let defaultEffort;
435
+ /** Resolve the live route's effort levels + adapter default through the
436
+ * llm runtime; 'unavailable' when the service is unmounted, 'error' when
437
+ * resolution throws (notified here). */
438
+ const resolveEfforts = async () => {
439
+ if (llmRuntime === undefined)
440
+ return 'unavailable';
407
441
  try {
408
442
  const info = await llmRuntime.resolveModelInfo(state.provider, state.model);
409
- efforts = info.reasoning?.efforts ?? [];
410
- defaultEffort = info.reasoning?.defaultEffort;
443
+ return {
444
+ efforts: info.reasoning?.efforts ?? [],
445
+ defaultEffort: info.reasoning?.defaultEffort,
446
+ };
411
447
  }
412
448
  catch (error) {
413
449
  state.notify(t('effort-read-failed', { error: error instanceof Error ? error.message : String(error) }), {
414
450
  color: 'error',
415
451
  timeoutMs: 8000,
416
452
  });
417
- return;
418
- }
419
- if (efforts.length <= 1) {
420
- state.notify(efforts.length === 1
421
- ? t('effort-single-tier', { name: efforts[0].name })
422
- : t('effort-unsupported'), { color: 'warning' });
423
- return;
453
+ return 'error';
424
454
  }
425
- // No explicit level yet = the adapter's materialized default is in
426
- // effect; cycle from THERE, not from the top of the list.
427
- const currentId = state.reasoningEffort ?? defaultEffort;
428
- const currentIndex = efforts.findIndex(effort => effort.id === currentId);
429
- const next = efforts[(currentIndex + 1) % efforts.length];
455
+ };
456
+ /** Pin one validated effort level on the live route: reroutes the next
457
+ * request, persists the choice, and refreshes the StatusLine segment. */
458
+ const applyEffort = (effort) => {
430
459
  selection.current = {
431
460
  provider: state.provider,
432
461
  model: state.model,
433
- reasoningEffort: ReasoningEffortId(next.id),
462
+ reasoningEffort: ReasoningEffortId(effort.id),
434
463
  };
435
- preferredEffort = next.id;
436
- state.reasoningEffort = next.id;
437
- writeEffortPref(next.id);
438
- state.notify(t('effort-switched', { name: next.name }));
464
+ preferredEffort = effort.id;
465
+ state.reasoningEffort = effort.id;
466
+ writeEffortPref(effort.id);
467
+ state.notify(t('effort-switched', { name: effort.name }));
468
+ state.emit();
469
+ };
470
+ /** The live route's effort levels for the `/effort` slider; empty after
471
+ * notifying when the route is unsupported/unavailable/single-tier. */
472
+ const listEfforts = async () => {
473
+ const resolved = await resolveEfforts();
474
+ if (resolved === 'unavailable') {
475
+ state.notify(t('effort-unavailable'), { color: 'error' });
476
+ return { efforts: [], defaultEffort: undefined };
477
+ }
478
+ if (resolved === 'error')
479
+ return { efforts: [], defaultEffort: undefined };
480
+ if (resolved.efforts.length === 0) {
481
+ state.notify(t('effort-unsupported'), { color: 'warning' });
482
+ }
483
+ else if (resolved.efforts.length === 1) {
484
+ state.notify(t('effort-single-tier', { name: resolved.efforts[0].name }), { color: 'warning' });
485
+ }
486
+ return resolved;
487
+ };
488
+ /** Set one effort level by id (`/effort <id>` and the slider's live
489
+ * apply); false + a notify when the id is not offered by the route. */
490
+ const setEffort = async (id) => {
491
+ const resolved = await resolveEfforts();
492
+ if (resolved === 'unavailable') {
493
+ state.notify(t('effort-unavailable'), { color: 'error' });
494
+ return false;
495
+ }
496
+ if (resolved === 'error')
497
+ return false;
498
+ if (resolved.efforts.length === 0) {
499
+ state.notify(t('effort-unsupported'), { color: 'warning' });
500
+ return false;
501
+ }
502
+ const found = resolved.efforts.find(effort => effort.id === id);
503
+ if (!found) {
504
+ state.notify(t('effort-invalid', { id, ids: resolved.efforts.map(effort => effort.id).join(', ') }), { color: 'warning' });
505
+ return false;
506
+ }
507
+ applyEffort(found);
508
+ return true;
509
+ };
510
+ /** Run one DSH registry command (`/plan`, …) on the live agent; the text
511
+ * of its result, '' when the result is textless, undefined when the
512
+ * command is not registered, and the error message when it throws. */
513
+ const executeRegistryCommand = async (name, rawInput) => {
514
+ if (!commandService)
515
+ return undefined;
516
+ try {
517
+ const execution = await commandService.execute(agent, `/${name}${rawInput}`, new AbortController().signal);
518
+ // `undefined` = not registered; a handler error surfaces as its
519
+ // message so the user sees why the command failed.
520
+ return execution?.result.text ?? '';
521
+ }
522
+ catch (error) {
523
+ return error instanceof Error ? error.message : String(error);
524
+ }
525
+ };
526
+ // Session-mode folds: last-wins projections over the session log. The
527
+ // event types are registered by dsh-plan-mode / dsh-sandbox-policy /
528
+ // dsh-user-approval and are NOT in this package's typed SessionEvent
529
+ // union, so they are matched by name through casts — the same pattern as
530
+ // `agent-preset/selected` in renderEvent and the goal projection above.
531
+ const foldPlanActive = (events) => {
532
+ let active = false;
533
+ for (const event of events) {
534
+ if (event.type === 'plan/mode') {
535
+ active = event.data.active === true;
536
+ }
537
+ }
538
+ return active;
539
+ };
540
+ const foldSandboxMode = (events) => {
541
+ let mode;
542
+ for (const event of events) {
543
+ if (event.type === 'sandbox/mode') {
544
+ const value = event.data.mode;
545
+ if (typeof value === 'string')
546
+ mode = value;
547
+ }
548
+ }
549
+ return mode;
550
+ };
551
+ const foldApprovalPolicy = (events) => {
552
+ let policy;
553
+ for (const event of events) {
554
+ if (event.type === 'approval/policy') {
555
+ const value = event.data.policy;
556
+ if (typeof value === 'string')
557
+ policy = value;
558
+ }
559
+ }
560
+ return policy;
561
+ };
562
+ /** First configured mode whose declared atoms all match the folds;
563
+ * undeclared atoms are wildcards; no match → index 0 (the base mode).
564
+ * Matching is exact: a fresh session has no `approval/policy` event, so
565
+ * a mode declaring `approval: 'ask'` never falsely matches it. */
566
+ const deriveModeIndex = (events) => {
567
+ const index = sessionModes.findIndex(spec => (spec.plan === undefined || foldPlanActive(events) === spec.plan) &&
568
+ (spec.sandbox === undefined || foldSandboxMode(events) === spec.sandbox) &&
569
+ (spec.approval === undefined || foldApprovalPolicy(events) === spec.approval));
570
+ return index >= 0 ? index : 0;
571
+ };
572
+ /** Re-derive the current mode from the live session log (boot, every
573
+ * agent re-bind, and after mode-affecting session events). */
574
+ const refreshMode = () => {
575
+ state.modeIndex = deriveModeIndex(agent.session.events);
576
+ state.mode = sessionModes[state.modeIndex];
577
+ };
578
+ /** Apply one configured mode: each declared atom switches independently
579
+ * (plan via the registry `/plan` command; sandbox/approval via their
580
+ * durable session-log override events). A failing plan toggle aborts the
581
+ * whole switch so the session never lands in a half-applied mode. */
582
+ const applyMode = async (spec) => {
583
+ if (spec.plan !== undefined && foldPlanActive(agent.session.events) !== spec.plan) {
584
+ const text = await executeRegistryCommand('plan', spec.plan ? '' : ' off');
585
+ if (text === undefined) {
586
+ // The active preset registers no /plan.
587
+ state.notify(t('mode-plan-unavailable'), { color: 'warning' });
588
+ return;
589
+ }
590
+ }
591
+ // The durable sandbox override is one session event (dsh-sandbox-policy's
592
+ // own write path); the session/event arm picks it up immediately.
593
+ if (spec.sandbox !== undefined && foldSandboxMode(agent.session.events) !== spec.sandbox) {
594
+ ;
595
+ agent.session.append('sandbox/mode', { mode: spec.sandbox });
596
+ }
597
+ // Prefer the approval service (it narrates the switch to the model);
598
+ // the raw durable event is the fallback when it is unmounted.
599
+ if (spec.approval !== undefined && foldApprovalPolicy(agent.session.events) !== spec.approval) {
600
+ const approval = ctx.get('approval');
601
+ if (approval) {
602
+ approval.setPolicy(agent, spec.approval);
603
+ }
604
+ else {
605
+ ;
606
+ agent.session.append('approval/policy', { policy: spec.approval });
607
+ }
608
+ }
609
+ refreshMode();
610
+ state.notify(t('mode-switched', { name: modeDisplayName(state.mode) }));
439
611
  state.emit();
440
612
  };
613
+ /** Shift+Tab: advance to the next configured session mode. Cycling starts
614
+ * from the mode DERIVED from the session log (never a stored index), so
615
+ * manual `/plan` use can never desync the cycle. */
616
+ const cycleMode = async () => {
617
+ const index = deriveModeIndex(agent.session.events);
618
+ await applyMode(sessionModes[(index + 1) % sessionModes.length]);
619
+ };
441
620
  const state = {
442
621
  version: 0,
443
622
  rows: [],
@@ -448,6 +627,7 @@ export function createChannel(ctx, initialAgent, options) {
448
627
  provider: options.provider,
449
628
  tokens: { input: 0, output: 0 },
450
629
  cwd: options.cwd,
630
+ displayCwd: workspaceService.describe(options.cwd).description ?? options.cwd,
451
631
  gitBranch: undefined,
452
632
  working: false,
453
633
  spinnerMode: 'requesting',
@@ -457,9 +637,13 @@ export function createChannel(ctx, initialAgent, options) {
457
637
  lastUserText: '',
458
638
  notifications: [],
459
639
  contextWindow: undefined,
460
- // Explicit cordis.yml `effort` wins; otherwise the persisted Shift+Tab
640
+ // Explicit cordis.yml `effort` wins; otherwise the persisted /effort
461
641
  // choice; the first request/header event re-asserts the adapter's truth.
462
642
  reasoningEffort: options.effort ?? readEffortPref(),
643
+ // Session-mode seed; the first refreshMode() (bindAgent) re-derives it
644
+ // from the session log, so a resumed session lands on its recorded mode.
645
+ mode: sessionModes[0],
646
+ modeIndex: 0,
463
647
  workingActivity: undefined,
464
648
  activityFrames: options.activityFrames,
465
649
  activityEnabled: options.activity !== false,
@@ -470,6 +654,29 @@ export function createChannel(ctx, initialAgent, options) {
470
654
  loadedContext: undefined,
471
655
  pending: [],
472
656
  commandList: LOCAL_COMMANDS,
657
+ commandCompletions(input) {
658
+ return completeCommands(input, state.commandList, (path) => {
659
+ if (path.length === 1 && path[0] === 'workspace') {
660
+ const builtins = [
661
+ { name: 'resume', description: 'Switch to another workspace', descriptionKey: 'cmd-desc-workspace-resume' },
662
+ { name: 'rename', description: 'Rename the current workspace', descriptionKey: 'cmd-desc-workspace-rename' },
663
+ { name: 'open', description: 'Open a path or workspace URI', descriptionKey: 'cmd-desc-workspace-open' },
664
+ ];
665
+ const reserved = new Set(builtins.map(command => command.name));
666
+ return [
667
+ ...builtins,
668
+ ...workspaceService.commands()
669
+ .filter(command => !reserved.has(command.name.toLowerCase()))
670
+ .map(command => ({
671
+ name: command.name,
672
+ aliases: command.aliases,
673
+ description: command.description,
674
+ })),
675
+ ];
676
+ }
677
+ return commandTrees?.children(path) ?? [];
678
+ });
679
+ },
473
680
  lastUsage: undefined,
474
681
  tps: undefined,
475
682
  tpsSamples: [],
@@ -522,6 +729,30 @@ export function createChannel(ctx, initialAgent, options) {
522
729
  state.emit();
523
730
  return restored;
524
731
  },
732
+ async stageImage(input) {
733
+ const attachments = mentionAttachments(ctx);
734
+ if (attachments === undefined)
735
+ throw new Error('image attachments are unavailable in this profile');
736
+ if (!attachments.imageLimits.mediaTypes.includes(input.mediaType)) {
737
+ throw new Error(`${input.mediaType} images are not accepted by this profile`);
738
+ }
739
+ if (input.data.byteLength > attachments.imageLimits.maxImageBytes) {
740
+ throw new Error(`image exceeds this profile's per-image size limit`);
741
+ }
742
+ const attachment = await attachments.saveImage(input);
743
+ stagedImageSequence += 1;
744
+ const token = `[Image #${stagedImageSequence}]`;
745
+ stagedImages.set(token, attachment);
746
+ // References are content-addressed and durable. This map only connects
747
+ // editable prompt placeholders to them; cap it to bound a long TUI run.
748
+ while (stagedImages.size > 128) {
749
+ const oldest = stagedImages.keys().next().value;
750
+ if (oldest === undefined)
751
+ break;
752
+ stagedImages.delete(oldest);
753
+ }
754
+ return token;
755
+ },
525
756
  submit(text) {
526
757
  const trimmed = text.trim();
527
758
  if (!trimmed)
@@ -619,7 +850,7 @@ export function createChannel(ctx, initialAgent, options) {
619
850
  const sessions = ctx.get('sessions');
620
851
  const agents = ctx.get('agents');
621
852
  if (!sessions || !agents) {
622
- state.notify('Rewind unavailable — session services not loaded', { color: 'error' });
853
+ state.notify(t('rewind-unavailable'), { color: 'error' });
623
854
  return null;
624
855
  }
625
856
  // Stop a running turn first and WAIT for its turn/end to land — fork
@@ -632,7 +863,7 @@ export function createChannel(ctx, initialAgent, options) {
632
863
  if (wasWorking) {
633
864
  const turnSettled = await waitForTurnEnd(agent.session, cancelSeq, 30000);
634
865
  if (!turnSettled) {
635
- state.notify('Cannot rewind — the turn is still settling, try again in a moment', { color: 'error' });
866
+ state.notify(t('rewind-settling'), { color: 'error' });
636
867
  return null;
637
868
  }
638
869
  }
@@ -669,7 +900,7 @@ export function createChannel(ctx, initialAgent, options) {
669
900
  }
670
901
  catch (error) {
671
902
  const message = error instanceof Error ? error.message : String(error);
672
- state.notify(`Cannot rewind to this point · ${message}`, { color: 'error' });
903
+ state.notify(t('rewind-fork-failed', { err: message }), { color: 'error' });
673
904
  return null;
674
905
  }
675
906
  let handle;
@@ -684,7 +915,7 @@ export function createChannel(ctx, initialAgent, options) {
684
915
  sessionId: childId,
685
916
  seed,
686
917
  meta: {
687
- cwd: options.cwd,
918
+ cwd: state.cwd,
688
919
  parentSession: agent.session.id,
689
920
  seedLength: seed.length,
690
921
  ...(rewindComposed.agentPreset === undefined
@@ -696,9 +927,15 @@ export function createChannel(ctx, initialAgent, options) {
696
927
  });
697
928
  }
698
929
  catch {
699
- state.notify('Rewind failed — could not create the replacement session', { color: 'error' });
930
+ state.notify(t('rewind-create-failed'), { color: 'error' });
700
931
  return null;
701
932
  }
933
+ try {
934
+ await attachSessionToWorkspace(ctx, state.cwd, childId);
935
+ }
936
+ catch (error) {
937
+ state.notify(t('rewind-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
938
+ }
702
939
  // Replay the forked history into a fresh transcript (tokens/spinner
703
940
  // counters land back at the rewind point, matching the fork).
704
941
  streaming = undefined;
@@ -709,6 +946,11 @@ export function createChannel(ctx, initialAgent, options) {
709
946
  // Goal/todo/title are session-scoped; the replay re-derives them for
710
947
  // the session being entered (or leaves them empty).
711
948
  state.todos = [];
949
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
950
+ // swap must drop their previews or they linger forever (unretirable —
951
+ // retire events are filtered to the new agent, unwithdrawable — the
952
+ // new inbox never heard of them).
953
+ state.pending = [];
712
954
  state.goal = undefined;
713
955
  state.sessionTitle = '';
714
956
  state.tokens = { input: 0, output: 0 };
@@ -740,6 +982,7 @@ export function createChannel(ctx, initialAgent, options) {
740
982
  bindAgent();
741
983
  refreshCommandList();
742
984
  void refreshLoadedContext();
985
+ void refreshSkillCommands();
743
986
  // The forked session (rewind) becomes the most recently used.
744
987
  touchSession(childId);
745
988
  state.emit();
@@ -749,17 +992,24 @@ export function createChannel(ctx, initialAgent, options) {
749
992
  async resumeTo(sessionId) {
750
993
  // Switch the live agent to a persisted session: /resume picker Enter
751
994
  // loads the history immediately (the `--resume` launcher path keeps
752
- // resolving through DSH_CC_RESUME_SESSION at boot).
995
+ // resolving through DSH_TUI_RESUME_SESSION at boot).
753
996
  if (state.working) {
754
- state.notify('Cannot resume while a turn is running', { color: 'warning' });
997
+ state.notify(t('resume-while-working'), { color: 'warning' });
755
998
  return false;
756
999
  }
757
1000
  const agents = ctx.get('agents');
758
1001
  if (!agents) {
759
- state.notify('Resume unavailable — agents service not loaded', { color: 'error' });
1002
+ state.notify(t('resume-unavailable'), { color: 'error' });
760
1003
  return false;
761
1004
  }
762
1005
  let handle;
1006
+ // Compat boundary: register vouched-for legacy event types (e.g.
1007
+ // activity/status from pre-#143 logs) in every reachable dsh-session
1008
+ // copy before ANY strict read path (preset lookup below, then the
1009
+ // harness seed validation) loads the target — the plugin's #119
1010
+ // registration never ran in processes where it is unmounted (issue
1011
+ // #153). In-process only: the shared log is never rewritten.
1012
+ ensureLegacySessionEventTypes();
763
1013
  // The target session's own preset (from its persisted log) — never the
764
1014
  // current preference: a resume re-enters the composition its history
765
1015
  // was produced under. Same rule for the route: only an explicit
@@ -773,9 +1023,6 @@ export function createChannel(ctx, initialAgent, options) {
773
1023
  model: options.configuredModel,
774
1024
  });
775
1025
  try {
776
- // Compat boundary: mark third-party event types in the target log
777
- // ignorable before the harness seed-validates it (src/compat/).
778
- await prepareSessionForResume(sessionId);
779
1026
  handle = await agents.resume({
780
1027
  resumeSessionId: SessionId(sessionId),
781
1028
  agentOptions: { provider: resumeRoute?.provider, model: resumeRoute?.model },
@@ -784,9 +1031,18 @@ export function createChannel(ctx, initialAgent, options) {
784
1031
  }
785
1032
  catch (error) {
786
1033
  const message = error instanceof Error ? error.message : String(error);
787
- state.notify(`Resume failed · ${message}`, { color: 'error', timeoutMs: 8000 });
1034
+ state.notify(t('resume-failed', { err: message }), { color: 'error', timeoutMs: 8000 });
788
1035
  return false;
789
1036
  }
1037
+ try {
1038
+ // `/resume` is an explicit adoption of this persisted conversation.
1039
+ // This also repairs sessions created by TUI versions that predate the
1040
+ // separate workspace ownership ledger.
1041
+ await attachSessionToWorkspace(ctx, handle.agent.session.header.cwd ?? state.cwd, SessionId(sessionId));
1042
+ }
1043
+ catch (error) {
1044
+ state.notify(t('resume-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1045
+ }
790
1046
  // Replay the persisted history into a fresh transcript (same reset as
791
1047
  // rewindTo, plus the context window which the replay re-derives).
792
1048
  streaming = undefined;
@@ -797,6 +1053,11 @@ export function createChannel(ctx, initialAgent, options) {
797
1053
  // Goal/todo/title are session-scoped; the replay re-derives them for
798
1054
  // the session being entered (or leaves them empty).
799
1055
  state.todos = [];
1056
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
1057
+ // swap must drop their previews or they linger forever (unretirable —
1058
+ // retire events are filtered to the new agent, unwithdrawable — the
1059
+ // new inbox never heard of them).
1060
+ state.pending = [];
800
1061
  state.goal = undefined;
801
1062
  state.sessionTitle = '';
802
1063
  state.tokens = { input: 0, output: 0 };
@@ -807,6 +1068,15 @@ export function createChannel(ctx, initialAgent, options) {
807
1068
  state.spinnerMode = 'requesting';
808
1069
  state.status = handle.agent.status;
809
1070
  state.agentId = handle.agent.id;
1071
+ // Adopt the resumed session's persisted cwd (issue #96): pre-upgrade
1072
+ // sessions recorded the LAUNCH directory (often a repo subdirectory),
1073
+ // so keeping the freshly resolved root would split @ expansion / file
1074
+ // completion (state.cwd) from the agent's own workspace record — and
1075
+ // drop the session back out of the /resume filter. The branch
1076
+ // breadcrumb follows the adopted cwd.
1077
+ state.cwd = handle.agent.session.header.cwd ?? state.cwd;
1078
+ state.displayCwd = workspaceService.describe(state.cwd).description ?? state.cwd;
1079
+ refreshGitBranch();
810
1080
  state.agentPreset = resumeComposed.agentPreset;
811
1081
  // Status-line route follows the resumed session (review feedback): the
812
1082
  // route it actually continues on — a complete cordis.yml pin, else the
@@ -839,12 +1109,14 @@ export function createChannel(ctx, initialAgent, options) {
839
1109
  bindAgent();
840
1110
  refreshCommandList();
841
1111
  void refreshLoadedContext();
1112
+ void refreshSkillCommands();
842
1113
  // Keep the `--resume` launcher contract pointing at the same session.
843
1114
  writeResumeTarget(sessionId);
844
1115
  // The resumed session is now the most recently used.
845
1116
  touchSession(sessionId);
846
1117
  state.emit();
847
1118
  void oldHandle?.dispose().catch(() => { });
1119
+ clearStagedImages();
848
1120
  return true;
849
1121
  },
850
1122
  async newSession() {
@@ -852,14 +1124,14 @@ export function createChannel(ctx, initialAgent, options) {
852
1124
  // transcript reset, the `--resume` marker forgotten (the old session
853
1125
  // stays persisted for /resume). Same reset shape as rewindTo/resumeTo.
854
1126
  if (state.working) {
855
- state.notify('Cannot start a new session while a turn is running', {
1127
+ state.notify(t('new-session-while-working'), {
856
1128
  color: 'warning',
857
1129
  });
858
1130
  return false;
859
1131
  }
860
1132
  const agents = ctx.get('agents');
861
1133
  if (!agents) {
862
- state.notify('New session unavailable — agents service not loaded', {
1134
+ state.notify(t('new-session-unavailable'), {
863
1135
  color: 'error',
864
1136
  });
865
1137
  return false;
@@ -893,7 +1165,7 @@ export function createChannel(ctx, initialAgent, options) {
893
1165
  handle = await agents.create({
894
1166
  sessionId,
895
1167
  meta: {
896
- cwd: options.cwd,
1168
+ cwd: state.cwd,
897
1169
  ...(newComposed.agentPreset === undefined
898
1170
  ? {}
899
1171
  : { agentPreset: newComposed.agentPreset }),
@@ -904,12 +1176,18 @@ export function createChannel(ctx, initialAgent, options) {
904
1176
  }
905
1177
  catch (error) {
906
1178
  const message = error instanceof Error ? error.message : String(error);
907
- state.notify(`New session failed · ${message}`, {
1179
+ state.notify(t('new-session-failed', { err: message }), {
908
1180
  color: 'error',
909
1181
  timeoutMs: 8000,
910
1182
  });
911
1183
  return false;
912
1184
  }
1185
+ try {
1186
+ await attachSessionToWorkspace(ctx, state.cwd, sessionId);
1187
+ }
1188
+ catch (error) {
1189
+ state.notify(t('new-session-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1190
+ }
913
1191
  streaming = undefined;
914
1192
  reasoning = undefined;
915
1193
  toolCards.clear();
@@ -918,6 +1196,11 @@ export function createChannel(ctx, initialAgent, options) {
918
1196
  // Goal/todo/title are session-scoped; the replay re-derives them for
919
1197
  // the session being entered (or leaves them empty).
920
1198
  state.todos = [];
1199
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
1200
+ // swap must drop their previews or they linger forever (unretirable —
1201
+ // retire events are filtered to the new agent, unwithdrawable — the
1202
+ // new inbox never heard of them).
1203
+ state.pending = [];
921
1204
  state.goal = undefined;
922
1205
  state.sessionTitle = '';
923
1206
  state.tokens = { input: 0, output: 0 };
@@ -949,20 +1232,79 @@ export function createChannel(ctx, initialAgent, options) {
949
1232
  bindAgent();
950
1233
  refreshCommandList();
951
1234
  void refreshLoadedContext();
1235
+ void refreshSkillCommands();
952
1236
  clearResumeTarget();
953
1237
  // The brand-new session becomes the most recently used.
954
1238
  touchSession(handle.agent.id);
955
- state.emit();
956
1239
  void oldHandle?.dispose().catch(() => { });
1240
+ clearStagedImages();
1241
+ return true;
1242
+ },
1243
+ listWorkspaces() {
1244
+ return workspaceService.list(state.cwd);
1245
+ },
1246
+ resolveWorkspace(uri) {
1247
+ return workspaceService.resolve(uri, state.cwd);
1248
+ },
1249
+ async switchWorkspace(target) {
1250
+ if (state.working) {
1251
+ state.notify(t('workspace-switch-working'), { color: 'warning' });
1252
+ return false;
1253
+ }
1254
+ // Local targets must exist and be directories — creating a session in
1255
+ // a typo'd cwd "succeeds" and then every file tool errors per call.
1256
+ if (target.kind === 'local') {
1257
+ try {
1258
+ if (!statSync(target.cwd).isDirectory())
1259
+ throw new Error('not a directory');
1260
+ }
1261
+ catch {
1262
+ state.notify(t('workspace-open-invalid', { target: target.label }), { color: 'error', timeoutMs: 8000 });
1263
+ return false;
1264
+ }
1265
+ }
1266
+ const previousCwd = state.cwd;
1267
+ const previousDisplay = state.displayCwd;
1268
+ state.cwd = target.cwd;
1269
+ state.displayCwd = target.description ?? target.uri;
1270
+ const switched = await state.newSession();
1271
+ if (!switched) {
1272
+ state.cwd = previousCwd;
1273
+ state.displayCwd = previousDisplay;
1274
+ return false;
1275
+ }
1276
+ // The breadcrumb follows the adopted cwd, same as /resume (#96).
1277
+ refreshGitBranch();
1278
+ state.notify(t('workspace-switched', { target: target.label }));
1279
+ state.emit();
957
1280
  return true;
958
1281
  },
1282
+ async renameWorkspace(title) {
1283
+ try {
1284
+ const renamed = await workspaceService.rename(state.cwd, title);
1285
+ state.displayCwd = renamed.description ?? renamed.uri;
1286
+ state.notify(t('workspace-renamed', { title: renamed.label }));
1287
+ state.emit();
1288
+ return true;
1289
+ }
1290
+ catch (error) {
1291
+ state.notify(t('workspace-rename-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
1292
+ return false;
1293
+ }
1294
+ },
1295
+ workspaceCommands() {
1296
+ return workspaceService.commands();
1297
+ },
1298
+ runWorkspaceCommand(name, input) {
1299
+ return workspaceService.runCommand(name, input, state.cwd);
1300
+ },
959
1301
  async switchModel(provider, model) {
960
1302
  // `/model` picker Enter — switch the live model by forking the
961
1303
  // conversation at its current end and continuing with a new agent
962
1304
  // routed to the chosen model. Same reset shape as rewindTo/resumeTo;
963
1305
  // the history replays unchanged, only the request model changes.
964
1306
  if (state.working) {
965
- state.notify('Cannot switch models while a turn is running', {
1307
+ state.notify(t('model-switch-while-working'), {
966
1308
  color: 'warning',
967
1309
  });
968
1310
  return false;
@@ -970,7 +1312,7 @@ export function createChannel(ctx, initialAgent, options) {
970
1312
  const sessions = ctx.get('sessions');
971
1313
  const agents = ctx.get('agents');
972
1314
  if (!sessions || !agents) {
973
- state.notify('Model switch unavailable — session services not loaded', {
1315
+ state.notify(t('model-switch-unavailable'), {
974
1316
  color: 'error',
975
1317
  });
976
1318
  return false;
@@ -982,7 +1324,7 @@ export function createChannel(ctx, initialAgent, options) {
982
1324
  }
983
1325
  catch (error) {
984
1326
  const message = error instanceof Error ? error.message : String(error);
985
- state.notify(`Cannot switch models · ${message}`, { color: 'error' });
1327
+ state.notify(t('model-switch-fork-failed', { err: message }), { color: 'error' });
986
1328
  return false;
987
1329
  }
988
1330
  const childId = SessionId(randomUUID());
@@ -995,7 +1337,7 @@ export function createChannel(ctx, initialAgent, options) {
995
1337
  sessionId: childId,
996
1338
  seed,
997
1339
  meta: {
998
- cwd: options.cwd,
1340
+ cwd: state.cwd,
999
1341
  parentSession: agent.session.id,
1000
1342
  seedLength: seed.length,
1001
1343
  ...(modelComposed.agentPreset === undefined
@@ -1008,9 +1350,15 @@ export function createChannel(ctx, initialAgent, options) {
1008
1350
  }
1009
1351
  catch (error) {
1010
1352
  const message = error instanceof Error ? error.message : String(error);
1011
- state.notify(`Model switch failed · ${message}`, { color: 'error', timeoutMs: 8000 });
1353
+ state.notify(t('model-switch-failed', { err: message }), { color: 'error', timeoutMs: 8000 });
1012
1354
  return false;
1013
1355
  }
1356
+ try {
1357
+ await attachSessionToWorkspace(ctx, state.cwd, childId);
1358
+ }
1359
+ catch (error) {
1360
+ state.notify(t('model-switch-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1361
+ }
1014
1362
  streaming = undefined;
1015
1363
  reasoning = undefined;
1016
1364
  toolCards.clear();
@@ -1019,6 +1367,11 @@ export function createChannel(ctx, initialAgent, options) {
1019
1367
  // Goal/todo/title are session-scoped; the replay re-derives them for
1020
1368
  // the session being entered (or leaves them empty).
1021
1369
  state.todos = [];
1370
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
1371
+ // swap must drop their previews or they linger forever (unretirable —
1372
+ // retire events are filtered to the new agent, unwithdrawable — the
1373
+ // new inbox never heard of them).
1374
+ state.pending = [];
1022
1375
  state.goal = undefined;
1023
1376
  state.sessionTitle = '';
1024
1377
  state.tokens = { input: 0, output: 0 };
@@ -1053,12 +1406,13 @@ export function createChannel(ctx, initialAgent, options) {
1053
1406
  bindAgent();
1054
1407
  refreshCommandList();
1055
1408
  void refreshLoadedContext();
1409
+ void refreshSkillCommands();
1056
1410
  // The model-switched fork becomes the most recently used.
1057
1411
  touchSession(childId);
1058
1412
  state.emit();
1059
1413
  void oldHandle?.dispose().catch(() => { });
1060
1414
  // Persist the choice so the next boot and `/new` start on it (same
1061
- // contract as /preset and Shift+Tab effort; issues #14/#30). A failed
1415
+ // contract as /preset and /effort; issues #14/#30). A failed
1062
1416
  // write keeps the live switch but warns it will not survive a restart.
1063
1417
  if (!writeModelPref(provider, model)) {
1064
1418
  state.notify(t('model-pref-write-failed'), {
@@ -1067,7 +1421,9 @@ export function createChannel(ctx, initialAgent, options) {
1067
1421
  }
1068
1422
  return true;
1069
1423
  },
1070
- cycleEffort,
1424
+ listEfforts,
1425
+ setEffort,
1426
+ cycleMode,
1071
1427
  clear() {
1072
1428
  state.rows.length = 0;
1073
1429
  nextRowId = 0;
@@ -1210,6 +1566,105 @@ export function createChannel(ctx, initialAgent, options) {
1210
1566
  return Promise.all(providers.map(provider => llm.listModels(provider.id).catch(() => [])))
1211
1567
  .then(lists => lists.flat());
1212
1568
  },
1569
+ providerSetup() {
1570
+ // The `/provider` wizard's runtime surface, over the dsh-base seams:
1571
+ // settings (profile persistence), credentials (key storage) and the
1572
+ // llm runtime's configurable-provider directory + model discovery.
1573
+ // Structurally typed like the other optional seams in this file.
1574
+ const llm = ctx.get('llm');
1575
+ const settings = ctx.get('settings');
1576
+ const credentials = ctx.get('credentials');
1577
+ // Without dsh-llm-pi-ai there is no adapter watching the settings
1578
+ // section, so a written profile would never activate a route. The
1579
+ // adapter registers its `llm-pi-ai` settings namespace at mount, which
1580
+ // is the rc.6-observable mount signal (the newer
1581
+ // `listModelDiscoveryNamespaces()` does not exist in rc.6).
1582
+ if (!llm || !settings || !credentials
1583
+ || !settings.describe().some(descriptor => descriptor.ns === 'llm-pi-ai')) {
1584
+ return undefined;
1585
+ }
1586
+ const revision = () => settings.describe().find(descriptor => descriptor.ns === 'llm-pi-ai')?.revision;
1587
+ return {
1588
+ listCatalogProviders() {
1589
+ // declared === true marks routes the adapter knows only because a
1590
+ // stored profile names them (user-added); the rest are activatable
1591
+ // catalog routes.
1592
+ return llm.listConfigurableProviders()
1593
+ .filter(entry => entry.settingsNs === 'llm-pi-ai' && entry.declared !== true)
1594
+ .map(entry => ({ provider: entry.provider, displayName: entry.displayName }));
1595
+ },
1596
+ routeExists(route) {
1597
+ const section = settings.get('llm-pi-ai');
1598
+ return section?.providers !== undefined && route in section.providers;
1599
+ },
1600
+ discoverModels(request) {
1601
+ return llm.discoverModels('llm-pi-ai', request);
1602
+ },
1603
+ envShadows(ref) {
1604
+ return process.env[ref] !== undefined;
1605
+ },
1606
+ async readCredential(ref) {
1607
+ const resolved = await credentials.resolve(ref);
1608
+ return resolved?.value;
1609
+ },
1610
+ writeCredential(ref, value) {
1611
+ return credentials.set(ref, value);
1612
+ },
1613
+ removeCredential(ref) {
1614
+ return credentials.unset(ref);
1615
+ },
1616
+ async writeProfile(route, profile) {
1617
+ const ops = [{ op: 'set', path: ['providers', route], value: profile }];
1618
+ try {
1619
+ await settings.mutate('llm-pi-ai', ops, revision());
1620
+ }
1621
+ catch (error) {
1622
+ // One retry on a stale-revision conflict (a concurrent write
1623
+ // landed between describe and mutate); anything else propagates
1624
+ // so the wizard can report and roll back the credential.
1625
+ const code = error?.code;
1626
+ if (code !== 'SETTINGS_CONFLICT')
1627
+ throw error;
1628
+ await settings.mutate('llm-pi-ai', ops, revision());
1629
+ }
1630
+ },
1631
+ };
1632
+ },
1633
+ async sideQuestion(question, options) {
1634
+ // CC /btw:无工具单轮辅助调用,重放 deriveMessages() 前缀 + 一条
1635
+ // 包装问题。tools 永不传(侧问无工具是核心语义);usage 不回收
1636
+ // (skipCacheWrite 同义——答案不进主上下文也不进 token 计数)。
1637
+ const llm = ctx.get('llm');
1638
+ if (!llm)
1639
+ return { answer: null, error: t('btw-llm-unavailable') };
1640
+ const header = agent.session.requestHeader();
1641
+ const config = header?.config;
1642
+ const messages = [
1643
+ ...agent.session.deriveMessages(),
1644
+ createUserMessage({
1645
+ content: [{ type: 'text', text: wrapSideQuestion(question) }],
1646
+ source: { kind: 'plugin', plugin: 'dsh-tui/btw' },
1647
+ }),
1648
+ ];
1649
+ const request = {
1650
+ provider: config?.provider ?? state.provider,
1651
+ model: config?.model ?? state.model,
1652
+ messages,
1653
+ ...(header?.system !== undefined && { system: header.system }),
1654
+ ...(config?.reasoningEffort !== undefined && { reasoningEffort: config.reasoningEffort }),
1655
+ ...(config?.temperature !== undefined && { temperature: config.temperature }),
1656
+ ...(config?.maxTokens !== undefined && { maxTokens: config.maxTokens }),
1657
+ ...(config?.stop !== undefined && { stop: [...config.stop] }),
1658
+ sessionId: agent.session.id,
1659
+ ...(options?.signal && { signal: options.signal }),
1660
+ };
1661
+ return runSideQuestion({
1662
+ stream: llm.stream.bind(llm),
1663
+ options: request,
1664
+ onText: options?.onText,
1665
+ signal: options?.signal,
1666
+ });
1667
+ },
1213
1668
  listFiles() {
1214
1669
  const fs = ctx.get('fs');
1215
1670
  return listFilesDeep(fs, state.cwd);
@@ -1224,8 +1679,7 @@ export function createChannel(ctx, initialAgent, options) {
1224
1679
  const headers = await persistence.list();
1225
1680
  // 按工作目录隔离(Claude Code 的项目维度):/resume 只列出本会话
1226
1681
  // 目录启动的会话,别的项目的会话不出现在选择器里。
1227
- const cwd = state.cwd.replace(/\/+$/, '');
1228
- const local = headers.filter(header => (header.cwd ?? '').replace(/\/+$/, '') === cwd);
1682
+ const local = headers.filter(header => sessionCwdMatches(state.cwd, header.cwd ?? ''));
1229
1683
  // MRU ordering: DSH headers carry only createdAt, so dsh-tui keeps its
1230
1684
  // own last-used timestamps (touchSession on resume/submit/new) and
1231
1685
  // falls back to createdAt for sessions never touched in this install.
@@ -1247,8 +1701,8 @@ export function createChannel(ctx, initialAgent, options) {
1247
1701
  // useful label. Read via the tolerant compat log reader instead of
1248
1702
  // persistence.load: the backend validates every event against
1249
1703
  // KNOWN_SESSION_EVENT_TYPES and throws the whole load on an unmarked
1250
- // third-party type (activity/status before resume-repair), which used
1251
- // to leave every working-activity session titled with the cwd
1704
+ // an unregistered third-party type, which would otherwise leave the
1705
+ // affected session titled with the cwd
1252
1706
  // basename. An unreadable log keeps the basename fallback.
1253
1707
  const empty = new Set();
1254
1708
  for (const record of records.slice(0, SESSION_TITLE_DEPTH)) {
@@ -1284,6 +1738,41 @@ export function createChannel(ctx, initialAgent, options) {
1284
1738
  state.sessionTitle = title;
1285
1739
  state.emit();
1286
1740
  },
1741
+ async deleteSession(sessionId) {
1742
+ // The live session's log is still being appended by this process —
1743
+ // deleting it from under the writer is never offered in the picker
1744
+ // (the current session is filtered out), so refuse it here too.
1745
+ if (sessionId === agent.session.id)
1746
+ return false;
1747
+ if (deleteSessionLog(sessionId) !== 'deleted')
1748
+ return false;
1749
+ forgetSession(sessionId);
1750
+ // A resume marker naming the deleted session would make the next
1751
+ // `dsh-tui --resume` launch target a log that no longer exists.
1752
+ if (readResumeTarget() === sessionId)
1753
+ clearResumeTarget();
1754
+ return true;
1755
+ },
1756
+ async renameSessionTo(sessionId, title) {
1757
+ if (sessionId === agent.session.id) {
1758
+ // The live session renames through session.append so the firehose
1759
+ // updates the status line right away (same as /rename).
1760
+ agent.session.append('session/title', { title });
1761
+ state.sessionTitle = title;
1762
+ state.emit();
1763
+ return true;
1764
+ }
1765
+ if (appendSessionTitle(sessionId, title) !== 'appended')
1766
+ return false;
1767
+ // listSessions resolves persisted titles only for the MRU top
1768
+ // SESSION_TITLE_DEPTH; a rename does not change MRU by itself, so a
1769
+ // session beyond the window would keep showing the cwd-basename
1770
+ // fallback (in the next picker AND after restart) even though the
1771
+ // title event is durable. A rename IS user interaction with the
1772
+ // session — touching it pulls it into the title window.
1773
+ touchSession(sessionId);
1774
+ return true;
1775
+ },
1287
1776
  compact() {
1288
1777
  // DSH compaction service key: `ctx.compaction` (dsh-compaction's
1289
1778
  // CompactionEngine; dsh-compaction-basic provides it in the example
@@ -1292,38 +1781,28 @@ export function createChannel(ctx, initialAgent, options) {
1292
1781
  // scope chain first (minimal composes NO compaction: stays unavailable).
1293
1782
  const compactService = serviceForAgent(ctx, agent, 'compaction');
1294
1783
  if (!compactService) {
1295
- state.notify('Compaction unavailable · no compaction service in this leaf', {
1784
+ state.notify(t('compact-unavailable'), {
1296
1785
  color: 'warning',
1297
1786
  });
1298
1787
  return;
1299
1788
  }
1300
1789
  if (state.working) {
1301
- state.notify('Cannot compact while a turn is running', { color: 'warning' });
1790
+ state.notify(t('compact-while-working'), { color: 'warning' });
1302
1791
  return;
1303
1792
  }
1304
1793
  const signal = new AbortController().signal;
1305
- state.notify('Compacting conversation…');
1794
+ state.notify(t('compact-working'));
1306
1795
  void compactService
1307
1796
  .compactNow(agent, signal)
1308
1797
  .then((result) => {
1309
- state.notify(result ? 'Conversation compacted' : 'Nothing to compact');
1798
+ state.notify(result ? t('compact-done') : t('compact-nothing'));
1310
1799
  })
1311
1800
  .catch((error) => {
1312
- state.notify(`Compaction failed · ${error instanceof Error ? error.message : String(error)}`, { color: 'error', timeoutMs: 8000 });
1801
+ state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
1313
1802
  });
1314
1803
  },
1315
- async runExternalCommand(name, rawInput) {
1316
- if (!commandService)
1317
- return undefined;
1318
- try {
1319
- const execution = await commandService.execute(agent, `/${name}${rawInput}`, new AbortController().signal);
1320
- // `undefined` = not registered; a handler error surfaces as its
1321
- // message so the user sees why the command failed.
1322
- return execution?.result.text ?? '';
1323
- }
1324
- catch (error) {
1325
- return error instanceof Error ? error.message : String(error);
1326
- }
1804
+ runExternalCommand(name, rawInput) {
1805
+ return executeRegistryCommand(name, rawInput);
1327
1806
  },
1328
1807
  pushLocal(title, lines) {
1329
1808
  state.rows.push({ id: nextRowId++, kind: 'local', text: title });
@@ -1464,16 +1943,24 @@ export function createChannel(ctx, initialAgent, options) {
1464
1943
  lines.push(t('doctor-cwd', { cwd: state.cwd }));
1465
1944
  lines.push(t('doctor-context-window', { window: state.contextWindow ?? t('doctor-unknown') }));
1466
1945
  lines.push(`${t('doctor-session', { id: state.agentId })}${state.sessionTitle ? ' · ' + state.sessionTitle : ''}`);
1467
- const userHome = process.env.USERPROFILE ?? homedir();
1946
+ const userHome = homeDir();
1468
1947
  const configCandidates = [
1469
- join(userHome, '.dsh-cc/cordis.yml'),
1948
+ join(userHome, '.dsh-tui/cordis.yml'),
1470
1949
  join(userHome, '.dsh/profiles/dsh-tui/cordis.patch.yml'),
1471
1950
  ];
1472
1951
  for (const candidate of configCandidates) {
1473
1952
  lines.push(`${t('doctor-config', { candidate, state: existsSync(candidate) ? '✓' : t('doctor-config-missing') })}`);
1474
1953
  }
1475
- const sessionsDir = join(userHome, '.dsh-cc/sessions');
1476
- lines.push(`${t('doctor-storage', { dir: sessionsDir, state: existsSync(sessionsDir) ? '✓' : t('doctor-storage-uninit') })}`);
1954
+ // Session store candidates mirror the compat layer (sessionsRoots):
1955
+ // the active root depends on the composition (bare cordis.yml →
1956
+ // legacy ~/.dsh-tui, profile → $DSH_HOME/sessions), so list every
1957
+ // candidate with its own state instead of hardcoding one.
1958
+ for (const dir of sessionsRoots()) {
1959
+ lines.push(`${t('doctor-storage', { dir, state: existsSync(dir) ? '✓' : t('doctor-storage-uninit') })}`);
1960
+ }
1961
+ if (existsSync(LEGACY_DATA_DIR)) {
1962
+ lines.push(t('doctor-legacy-dir'));
1963
+ }
1477
1964
  return lines;
1478
1965
  },
1479
1966
  async listSubagents() {
@@ -1495,6 +1982,14 @@ export function createChannel(ctx, initialAgent, options) {
1495
1982
  return [t('subagent-query-failed', { err: error instanceof Error ? error.message : String(error) })];
1496
1983
  }
1497
1984
  },
1985
+ releaseContributions() {
1986
+ releaseSkillCommands();
1987
+ },
1988
+ traceEvents() {
1989
+ // Immutable per-append snapshot (dsh-session caches the frozen array);
1990
+ // reads follow agent swaps (/resume /rewind /new) automatically.
1991
+ return agent.session.events;
1992
+ },
1498
1993
  };
1499
1994
  /**
1500
1995
  * Assemble the context a fresh conversation for the live agent will load,
@@ -1536,10 +2031,20 @@ export function createChannel(ctx, initialAgent, options) {
1536
2031
  tools.push({ name: tool.name, description: tool.description ?? '' });
1537
2032
  }
1538
2033
  }
1539
- const discovered = await discoverBaselineInstructionFiles({ cwd: options.cwd });
2034
+ const renderedInstructions = await loadBaselineInstructions({
2035
+ cwd: state.cwd,
2036
+ maxBytes: 1024 * 1024,
2037
+ maxSourceBytes: 1024 * 1024,
2038
+ }, ctx.get('fs'));
1540
2039
  if (target !== agent)
1541
2040
  return;
1542
- files.push(...discovered.map(file => ({ displayPath: file.displayPath })));
2041
+ const instructionSources = renderedInstructions;
2042
+ const instructionPaths = new Set([
2043
+ ...(instructionSources?.represented ?? []).map(file => file.displayPath),
2044
+ ...(renderedInstructions?.omitted ?? []).map(file => file.displayPath),
2045
+ ...(renderedInstructions?.truncated ?? []).map(file => file.displayPath),
2046
+ ]);
2047
+ files.push(...[...instructionPaths].map(displayPath => ({ displayPath })));
1543
2048
  // The skills registry is host-plane but scope-layered: preset rows
1544
2049
  // (skill-filesystem) register into the preset's layer, so the catalog
1545
2050
  // must be read through the agent's scope chain (serviceForAgent falls
@@ -1563,51 +2068,289 @@ export function createChannel(ctx, initialAgent, options) {
1563
2068
  state.emit();
1564
2069
  };
1565
2070
  /**
1566
- * Rebuild the merged slash-command list from the registry. Registry
1567
- * registrations are global or agent-scoped, so this runs on
1568
- * `commands/change` and again whenever the live agent is swapped
1569
- * (rewind/resume).
2071
+ * Rebuild the merged slash-command list: built-in locals, then registry
2072
+ * commands (plan/goal/…), then user-invocable skills from the DSH skill
2073
+ * registry (issue #86 — filesystem-discovered skills must appear in the
2074
+ * `/` menu and Tab completion, like /audit and /review). Skill entries
2075
+ * are completion-only: dispatch falls through to the model as plain text,
2076
+ * where dsh-tool-skill's pre-step hook injects the skill body — the same
2077
+ * path a hand-typed `/skill-name` takes. Registry and skill reads are
2078
+ * scoped to the LIVE agent, so this runs on `commands/change` +
2079
+ * `skills/change` and again whenever the live agent is swapped
2080
+ * (rewind/resume/new/model). A failed skill read restores the last
2081
+ * successfully merged skill set for the same agent (last-good), so a
2082
+ * transient provider failure never makes known skills vanish.
1570
2083
  */
2084
+ let commandListSeq = 0;
2085
+ /**
2086
+ * The last successfully merged skill entries, tagged with the agent whose
2087
+ * scope produced them. A failed catalog read restores these instead of
2088
+ * dropping skill entries from the menu until the next successful refresh
2089
+ * (last-good); the agent tag refuses cross-agent restores — a different
2090
+ * scope's skills may not exist for the live agent at all.
2091
+ */
2092
+ let lastGoodSkills;
1571
2093
  const refreshCommandList = () => {
2094
+ const target = agent;
2095
+ const token = ++commandListSeq;
1572
2096
  const merged = [...LOCAL_COMMANDS];
1573
2097
  if (commandService) {
1574
- for (const descriptor of commandService.list(agent)) {
2098
+ for (const descriptor of commandService.list(target)) {
1575
2099
  if (merged.some(command => command.name === descriptor.name))
1576
2100
  continue;
2101
+ const descriptions = commandTrees?.descriptions(descriptor.name);
1577
2102
  merged.push({
1578
2103
  name: descriptor.name,
1579
2104
  description: descriptor.description,
2105
+ ...(descriptions === undefined ? {} : { descriptions }),
1580
2106
  tag: descriptor.input?.hint,
1581
2107
  external: true,
2108
+ // Skills reach the registry as ordinary commands, so the menu would
2109
+ // lose the marker HelpMenu uses to keep them out of the chrome list.
2110
+ // This channel registered them and is the authority on which names
2111
+ // are skills.
2112
+ ...(skillCommands.has(descriptor.name) ? { skill: true } : {}),
1582
2113
  });
1583
2114
  }
1584
2115
  }
1585
2116
  state.commandList = merged;
1586
2117
  state.emit();
2118
+ // The skill catalog resolves asynchronously (filesystem providers scan
2119
+ // their roots), so skills append in a continuation; a newer refresh or
2120
+ // an agent swap supersedes this run (token/identity check, same rule as
2121
+ // refreshLoadedContext). Locals and registry commands win name
2122
+ // collisions — a skill named `plan` must not shadow the registry's.
2123
+ const skillsService = serviceForAgent(ctx, target, 'skills');
2124
+ if (skillsService === undefined)
2125
+ return;
2126
+ /** Last-good restore shared by the failed-read and incomplete-read
2127
+ * paths; the caller holds the staleness check. */
2128
+ const restoreLastGood = () => {
2129
+ const fallback = lastGoodSkills?.agent === target ? lastGoodSkills.commands : [];
2130
+ const restored = fallback.filter(entry => !merged.some(command => command.name === entry.name));
2131
+ if (restored.length === 0)
2132
+ return;
2133
+ state.commandList = [...merged, ...restored];
2134
+ state.emit();
2135
+ };
2136
+ // snapshot() over list(): only a COMPLETE observation is authoritative
2137
+ // — list() discards `complete`, so a provider failure or a rescan still
2138
+ // in flight would resolve as a partial/empty catalog and wrongly clear
2139
+ // the last-good set (dsh-skill's own consumer contract).
2140
+ void skillsService.snapshot({
2141
+ scope: target,
2142
+ cwd: target.session.header?.cwd ?? state.cwd,
2143
+ }).then((observation) => {
2144
+ if (token !== commandListSeq || target !== agent)
2145
+ return;
2146
+ if (!observation.complete) {
2147
+ // Incomplete (provider failure/rescan mid-flight): NOT authoritative
2148
+ // — never clear last-good or repopulate from the partial catalog.
2149
+ // The provider's next invalidate fires skills/change for the retry.
2150
+ ctx.logger.warn('skill command merge: incomplete catalog observation, keeping last-good skills');
2151
+ restoreLastGood();
2152
+ return;
2153
+ }
2154
+ const withSkills = [...merged];
2155
+ for (const skill of observation.skills) {
2156
+ if (!isUserInvocable(skill))
2157
+ continue;
2158
+ if (withSkills.some(command => command.name === skill.name))
2159
+ continue;
2160
+ withSkills.push({ name: skill.name, description: skill.description, skill: true });
2161
+ }
2162
+ const added = withSkills.slice(merged.length);
2163
+ lastGoodSkills = { agent: target, commands: added };
2164
+ // The sync phase already assigned `merged`; a complete read that adds
2165
+ // nothing leaves the state as-is (and authoritatively clears the
2166
+ // last-good set above).
2167
+ if (added.length === 0)
2168
+ return;
2169
+ state.commandList = withSkills;
2170
+ state.emit();
2171
+ }).catch((error) => {
2172
+ // A superseded read (a newer refresh or an agent swap beat it) says
2173
+ // nothing about the live menu: stay silent instead of logging a
2174
+ // misleading failure warning.
2175
+ if (token !== commandListSeq || target !== agent)
2176
+ return;
2177
+ ctx.logger.warn('skill command merge failed: %o', error);
2178
+ // Last-good: a transient provider failure (rescan error, permission
2179
+ // hiccup) must not make known skills vanish from completion.
2180
+ restoreLastGood();
2181
+ });
1587
2182
  };
1588
2183
  ctx.on('commands/change', refreshCommandList);
2184
+ ctx.on('skills/change', refreshCommandList);
2185
+ /**
2186
+ * The view a skill-catalog read must be taken through, as ONE value.
2187
+ *
2188
+ * The registry is host-plane but scope-LAYERED: a provider mounted by an
2189
+ * agent preset's standing composition files into that preset's layer, and a
2190
+ * read taken without the scope sees only the host layer. Passing the pair
2191
+ * together keeps a read from being taken half-scoped.
2192
+ *
2193
+ * @param target - the agent whose view is wanted.
2194
+ */
2195
+ const skillViewOptions = (target) => ({
2196
+ scope: target,
2197
+ cwd: state.cwd,
2198
+ });
2199
+ /** The skill registry as the given agent sees it, or undefined when a boot
2200
+ * mounts none. `serviceForAgent` resolves through the agent's mount and
2201
+ * falls back to the host context. */
2202
+ const skillRegistryFor = (target) => serviceForAgent(ctx, target, 'skills');
2203
+ /**
2204
+ * Skill commands this channel owns, by skill name. The value keeps the
2205
+ * description the command was registered with so an edited SKILL.md
2206
+ * re-registers instead of leaving a stale menu entry.
2207
+ */
2208
+ const skillCommands = new Map();
2209
+ /** Skill names the registry refused (name taken, or invalid) — warn once. */
2210
+ const skillCommandsRefused = new Set();
2211
+ /** Pending re-read after an incomplete catalog observation. */
2212
+ let skillCommandsRetry;
2213
+ /**
2214
+ * Publish every user-invocable skill as a slash command (issue #86).
2215
+ *
2216
+ * The completion menu already lists these skills, but a menu entry is not a
2217
+ * command: nothing dispatches it, so typing the name and pressing Enter does
2218
+ * nothing. Registering through the host command registry is what makes them
2219
+ * runnable, and buys three things the TUI would otherwise reimplement:
2220
+ * `register` emits `commands/change`, so the menu merge folds the entry in
2221
+ * on its own; Enter dispatches through the normal command path, so the
2222
+ * invocation is logged as a paired `command/run`/`command/done` like every
2223
+ * other command; and the handler runs host-side, so invoking a skill is
2224
+ * DETERMINISTIC — the body is injected here, instead of sending `/name` to
2225
+ * the model and depending on it to recognize the text and reach for its
2226
+ * skill loader.
2227
+ *
2228
+ * `userInvocable` covers "human-facing command catalogs AND loaders", so
2229
+ * discovery alone would honor half the flag.
2230
+ */
2231
+ const refreshSkillCommands = async () => {
2232
+ if (commandService === undefined)
2233
+ return;
2234
+ const target = agent;
2235
+ const registry = skillRegistryFor(target);
2236
+ if (registry === undefined)
2237
+ return;
2238
+ let observation;
2239
+ try {
2240
+ observation = await registry.snapshot(skillViewOptions(target));
2241
+ }
2242
+ catch (error) {
2243
+ ctx.logger.warn('skill commands: catalog read failed: %o', error);
2244
+ return;
2245
+ }
2246
+ if (target !== agent)
2247
+ return;
2248
+ // A provider still warming its watcher reports an incomplete observation;
2249
+ // re-read once so a cold start cannot leave the menu permanently short.
2250
+ if (!observation.complete && skillCommandsRetry === undefined) {
2251
+ skillCommandsRetry = setTimeout(() => {
2252
+ skillCommandsRetry = undefined;
2253
+ void refreshSkillCommands();
2254
+ }, SKILL_COMMAND_RETRY_MS);
2255
+ }
2256
+ const wanted = new Map(observation.skills
2257
+ .filter(skill => isUserInvocable(skill))
2258
+ // A name the TUI's own command grammar cannot parse would show in the
2259
+ // menu and then fail to dispatch when typed; ask the real parser
2260
+ // instead of restating its pattern here.
2261
+ .filter(skill => parseCommandName(`/${skill.name}`)?.name === skill.name)
2262
+ // Built-in locals win a name collision, exactly as they do over
2263
+ // plugin-registered commands in refreshCommandList.
2264
+ .filter(skill => !isLocalCommandName(skill.name))
2265
+ .map(skill => [skill.name, skill.description]));
2266
+ for (const [name, entry] of skillCommands) {
2267
+ if (wanted.get(name) === entry.description)
2268
+ continue;
2269
+ entry.dispose();
2270
+ skillCommands.delete(name);
2271
+ }
2272
+ for (const [name, description] of wanted) {
2273
+ if (skillCommands.has(name) || skillCommandsRefused.has(name))
2274
+ continue;
2275
+ // Another plugin already owns this name (plan/goal/…): leave it alone.
2276
+ if (commandService.find(target, name) !== undefined)
2277
+ continue;
2278
+ try {
2279
+ const dispose = commandService.register({
2280
+ name,
2281
+ description,
2282
+ // The injected body is the payload; recording the (empty) raw input
2283
+ // would only duplicate the command name into the session log.
2284
+ recordInput: false,
2285
+ handler: async ({ agent: invoker, signal }) => {
2286
+ const view = { ...skillViewOptions(invoker), signal };
2287
+ const skill = await skillRegistryFor(invoker)?.get(name, view);
2288
+ if (skill === undefined || !isUserInvocable(skill)) {
2289
+ return { kind: 'error', text: t('skill-unavailable', { name }) };
2290
+ }
2291
+ // The official user-explicit invocation shape (dsh-skill's
2292
+ // SkillInvocationSource): the rendered body rides as instructions
2293
+ // the model follows, and transcript consumers present it from the
2294
+ // source metadata instead of re-parsing model-facing text.
2295
+ invoker.followup(createUserMessage({
2296
+ content: [{ type: 'text', text: renderSkillContent(skill) }],
2297
+ source: { kind: 'skill-invocation', name, form: 'instructions' },
2298
+ }));
2299
+ // Silent success: the agent visibly starts working on the skill,
2300
+ // which is the feedback (CC shows no banner either).
2301
+ return { kind: 'success' };
2302
+ },
2303
+ });
2304
+ skillCommands.set(name, { dispose, description });
2305
+ }
2306
+ catch (error) {
2307
+ skillCommandsRefused.add(name);
2308
+ ctx.logger.warn(`skill commands: "${name}" not registrable: %o`, error);
2309
+ }
2310
+ }
2311
+ };
2312
+ ctx.on('skills/change', () => {
2313
+ void refreshSkillCommands();
2314
+ });
2315
+ /** See {@link Channel.releaseContributions}. */
2316
+ const releaseSkillCommands = () => {
2317
+ if (skillCommandsRetry !== undefined)
2318
+ clearTimeout(skillCommandsRetry);
2319
+ skillCommandsRetry = undefined;
2320
+ for (const entry of skillCommands.values())
2321
+ entry.dispose();
2322
+ skillCommands.clear();
2323
+ };
1589
2324
  refreshCommandList();
1590
2325
  void refreshLoadedContext();
2326
+ void refreshSkillCommands();
1591
2327
  let nextRowId = 0;
1592
2328
  /** The leaf's bash executor (dsh-bash-local in the example leaf) — the DSH
1593
2329
  * execution seam for local `!` commands and the git status breadcrumb. The
1594
2330
  * service registers under `ctx.shell` (ShellExecutor; dsh-bash-local and
1595
2331
  * dsh-pwsh-local are the providers). */
1596
2332
  const bash = ctx.get('shell');
1597
- /** Claude Code's `!` mode: run a command on the user's machine and render its
1598
- * output in the transcript as local rows (never sent to the model). */
2333
+ /** Claude Code's `!` mode: execute in the current workspace provider and
2334
+ * render local-only transcript rows (never sent to the model). */
1599
2335
  const runLocalCommand = async (command, includeInContext) => {
1600
- state.rows.push({ id: nextRowId++, kind: 'local', text: command });
2336
+ const workspace = workspaceService.describe(state.cwd);
2337
+ state.rows.push({
2338
+ id: nextRowId++,
2339
+ kind: 'local',
2340
+ text: command,
2341
+ executionTarget: workspace.kind === 'local' ? workspace.badge : `${workspace.badge} · ${workspace.label}`,
2342
+ });
1601
2343
  state.emit();
1602
2344
  let output = '(no output)';
1603
- if (bash) {
2345
+ const executionShell = await workspaceService.commandShell(state.cwd) ?? bash;
2346
+ if (executionShell) {
1604
2347
  try {
1605
- const spec = bash.resolve({
2348
+ const spec = executionShell.resolve({
1606
2349
  command,
1607
2350
  workdir: state.cwd,
1608
2351
  timeoutMs: 30000,
1609
2352
  });
1610
- const result = await bash.run(spec);
2353
+ const result = await executionShell.run(spec);
1611
2354
  output =
1612
2355
  result.stdout.text.trim() ||
1613
2356
  result.stderr.text.trim() ||
@@ -2009,7 +2752,7 @@ ${output}
2009
2752
  const failure = event.data.error;
2010
2753
  if (failure !== undefined) {
2011
2754
  card.tool.status = 'error';
2012
- const errorText = `${failure.name}: ${failure.code}`;
2755
+ const errorText = toolErrorText(event);
2013
2756
  card.tool.errorText = errorText;
2014
2757
  state.contextSegments.tools += estimateTokens(errorText);
2015
2758
  }
@@ -2094,7 +2837,7 @@ ${output}
2094
2837
  state.rows.push({
2095
2838
  id: nextRowId,
2096
2839
  kind: 'interrupt',
2097
- text: 'Interrupted · What should Claude do instead?',
2840
+ text: t('interrupted-by-user') + t('interrupted-ask-next'),
2098
2841
  });
2099
2842
  nextRowId += 1;
2100
2843
  break;
@@ -2102,7 +2845,7 @@ ${output}
2102
2845
  const detail = reason.kind === 'error' ? reason.error.message : '';
2103
2846
  state.rows.push({ id: nextRowId, kind: 'notice', text: `turn ${reason.kind}${detail ? ` · ${detail}` : ''}` });
2104
2847
  nextRowId += 1;
2105
- state.notify(`Turn ${reason.kind}${detail ? ` · ${detail}` : ''}`, { color: 'error', timeoutMs: 8000 });
2848
+ state.notify(t('turn-failed', { detail: detail ? ` · ${detail}` : '' }), { color: 'error', timeoutMs: 8000 });
2106
2849
  break;
2107
2850
  }
2108
2851
  case 'request/context':
@@ -2137,7 +2880,7 @@ ${output}
2137
2880
  // Logged preset switch (blank sessions only, issue #8): a transcript
2138
2881
  // marker so a replayed log shows which composition produced the
2139
2882
  // turns after it. Not in dsh-session's typed union — matched here by
2140
- // name, the way `activity/status` is consumed in bindAgent.
2883
+ // name, like the other plugin-defined events above.
2141
2884
  if (event.type === 'agent-preset/selected') {
2142
2885
  const data = event.data;
2143
2886
  state.rows.push({
@@ -2159,29 +2902,97 @@ ${output}
2159
2902
  state.working = false;
2160
2903
  state.status = agent.status;
2161
2904
  state.emit();
2162
- // Live subscription list, rebound to a fresh agent by rewindTo.
2905
+ // Live subscription list and activity timer, rebound to every replacement
2906
+ // agent so no status from the previous session can leak across a swap.
2163
2907
  let agentSubscriptions = [];
2908
+ let activityTracker = new ActivityTracker({
2909
+ phrases: true,
2910
+ detailLimit: 40,
2911
+ showIdle: false,
2912
+ });
2913
+ let activityTickTimer;
2914
+ const stopActivityTick = () => {
2915
+ if (activityTickTimer === undefined)
2916
+ return;
2917
+ clearInterval(activityTickTimer);
2918
+ activityTickTimer = undefined;
2919
+ };
2920
+ /** Render the current tracker into the TUI-only projection. */
2921
+ const renderWorkingActivity = () => {
2922
+ if (options.activity === false) {
2923
+ state.workingActivity = undefined;
2924
+ return undefined;
2925
+ }
2926
+ const rendered = activityTracker.render();
2927
+ state.workingActivity = rendered;
2928
+ return rendered;
2929
+ };
2164
2930
  const bindAgent = () => {
2165
2931
  for (const dispose of agentSubscriptions)
2166
2932
  dispose();
2933
+ stopActivityTick();
2934
+ activityTracker = new ActivityTracker({
2935
+ phrases: true,
2936
+ detailLimit: 40,
2937
+ showIdle: false,
2938
+ });
2939
+ activityTracker.onAgentStatus(agent.status);
2940
+ renderWorkingActivity();
2941
+ activityTickTimer = setInterval(() => {
2942
+ const previous = state.workingActivity;
2943
+ const rendered = renderWorkingActivity();
2944
+ if (rendered === undefined)
2945
+ return;
2946
+ // Live phases deliberately wake at 500 ms even when the formatted line
2947
+ // has not crossed its next whole-second boundary: turnElapsedMs remains
2948
+ // a current state value, while line changes cover phrase rotation and
2949
+ // the short-lived completed-tool summary.
2950
+ if (rendered.phase === 'waiting' ||
2951
+ rendered.phase === 'thinking' ||
2952
+ rendered.phase === 'tool' ||
2953
+ previous?.phase !== rendered.phase ||
2954
+ previous.line !== rendered.line) {
2955
+ state.emit();
2956
+ }
2957
+ }, 500);
2958
+ activityTickTimer.unref();
2167
2959
  // Re-couple the channel-owned model selection to the new agent's
2168
2960
  // assembly/request waterfalls, then re-apply the persisted effort when
2169
2961
  // this agent's route offers it (dsh-agent installModelSelection).
2170
2962
  selection.current = undefined;
2171
2963
  selection.assembled = undefined;
2964
+ // {{model}} backfill (issue #155): a resumed agent's route lives only in
2965
+ // its session's request/header records — agentOptions.model stays
2966
+ // undefined unless cordis.yml pins a COMPLETE provider+model pair — so
2967
+ // the assemble-time persona variable `{{model}}` was registered but
2968
+ // valueless, and dsh-system-prompt's interpolate() throws before any
2969
+ // model call. Seed the selection from the channel's display route (on
2970
+ // resume it already carries the session's recorded route; on create it
2971
+ // matches the route the agent was created with). Per
2972
+ // installModelSelection's contract an absent effort restores the
2973
+ // provider/default behavior, so seeding never pins an effort the route
2974
+ // did not ask for; applyPreferredEffort below still upgrades the seed
2975
+ // when the user has a persisted preference the route offers.
2976
+ if (agent.options?.model === undefined && state.provider !== '' && state.model !== '') {
2977
+ selection.current = { provider: state.provider, model: state.model };
2978
+ }
2172
2979
  void applyPreferredEffort();
2980
+ refreshMode();
2173
2981
  agentSubscriptions = [
2174
2982
  installModelSelection(agent.ctx, selection),
2175
2983
  ctx.on('agent/status', ({ agent: subject, status }) => {
2176
2984
  if (subject !== agent)
2177
2985
  return;
2178
2986
  state.status = status;
2987
+ activityTracker.onAgentStatus(status);
2988
+ renderWorkingActivity();
2179
2989
  state.emit();
2180
2990
  }),
2181
2991
  ctx.on('agent/disposed', ({ agent: subject }) => {
2182
2992
  if (subject !== agent)
2183
2993
  return;
2184
2994
  state.status = 'disposed';
2995
+ stopActivityTick();
2185
2996
  state.emit();
2186
2997
  }),
2187
2998
  // Pending delivery is driven by the agent inbox: a claimed message
@@ -2215,24 +3026,13 @@ ${output}
2215
3026
  ctx.on('session/event', (session, event) => {
2216
3027
  if (session !== agent.session)
2217
3028
  return;
2218
- // Live working-activity snapshot (log-only event, appended by
2219
- // dsh-working-activity for UI consumers). Consumed here — NOT in
2220
- // renderEvent — so replayed history never resurrects a stale line
2221
- // (the renderEvent switch's default arm ignores it on replay).
2222
- if (options.activity !== false &&
2223
- event.type === 'activity/status') {
2224
- const data = event.data;
2225
- state.workingActivity = {
2226
- phase: data.phase,
2227
- line: data.line,
2228
- toolCount: data.toolCount ?? 0,
2229
- turnElapsedMs: data.turnElapsedMs ?? 0,
2230
- ...(data.label === undefined ? {} : { label: data.label }),
2231
- ...(data.detail === undefined ? {} : { detail: data.detail }),
2232
- ...(data.phrase === undefined ? {} : { phrase: data.phrase }),
2233
- };
2234
- state.emit();
2235
- return;
3029
+ activityTracker.onSessionEvent(event);
3030
+ renderWorkingActivity();
3031
+ // Mode-affecting atoms fold into the Shift+Tab mode indicator the
3032
+ // moment they land (whether appended by cycleMode or by hand).
3033
+ const eventType = event.type;
3034
+ if (eventType === 'plan/mode' || eventType === 'sandbox/mode' || eventType === 'approval/policy') {
3035
+ refreshMode();
2236
3036
  }
2237
3037
  renderEvent(event);
2238
3038
  // Streaming deltas (one event per token) take the frame-aligned
@@ -2245,15 +3045,30 @@ ${output}
2245
3045
  ];
2246
3046
  };
2247
3047
  bindAgent();
3048
+ // Cordis owns the Channel lifetime. Rebinding handles the common case;
3049
+ // this effect closes the final timer when the Channel's context unloads.
3050
+ const effect = ctx.effect;
3051
+ effect?.call(ctx, () => () => { stopActivityTick(); }, 'dsh-tui activity timer');
2248
3052
  // Statusline breadcrumb: current git branch of the session cwd (best-effort).
2249
- if (bash) {
3053
+ // Re-run when an agent swap adopts a different persisted cwd (/resume,
3054
+ // issue #96) so the breadcrumb never shows the previous workspace's branch.
3055
+ const refreshGitBranch = () => {
3056
+ state.gitBranch = undefined;
3057
+ if (!bash)
3058
+ return;
3059
+ // Capture the requested cwd: a /resume landing while this query is in
3060
+ // flight refreshes the branch for the NEW cwd, so a late reply from the
3061
+ // old workspace must be dropped (statusline staleness, issue #96 review).
3062
+ const requestedCwd = state.cwd;
2250
3063
  void bash
2251
3064
  .run(bash.resolve({
2252
3065
  command: 'git branch --show-current',
2253
- workdir: options.cwd,
3066
+ workdir: requestedCwd,
2254
3067
  timeoutMs: 3000,
2255
3068
  }))
2256
3069
  .then((result) => {
3070
+ if (state.cwd !== requestedCwd)
3071
+ return;
2257
3072
  const branch = result.stdout.text.trim();
2258
3073
  if (branch !== '') {
2259
3074
  state.gitBranch = branch;
@@ -2265,7 +3080,8 @@ ${output}
2265
3080
  // backend may be unavailable (no confinement yet) or the cwd may
2266
3081
  // not be a git repo. Either way the statusline simply stays blank.
2267
3082
  });
2268
- }
3083
+ };
3084
+ refreshGitBranch();
2269
3085
  return state;
2270
3086
  }
2271
3087
  /** Path basename for the resume-list title (`C:/a/b` → `b`). */
@@ -2273,6 +3089,56 @@ function basename(path) {
2273
3089
  const parts = path.split(/[\\/]/);
2274
3090
  return parts[parts.length - 1] ?? path;
2275
3091
  }
3092
+ /** Normalize a cwd for comparison: forward slashes, no trailing slash; case
3093
+ * folded when the platform's filesystem semantics are case-insensitive. */
3094
+ function normalizeCwd(path, caseInsensitive) {
3095
+ const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '');
3096
+ return caseInsensitive ? normalized.toLowerCase() : normalized;
3097
+ }
3098
+ /**
3099
+ * `/resume` project filter (issue #96): exact cwd match, PLUS sessions
3100
+ * recorded in a subdirectory — pre-upgrade launches recorded the launch
3101
+ * subdirectory as the header cwd, and with the cwd default now resolving to
3102
+ * the git worktree root an exact match would hide those sessions forever.
3103
+ * They belong to the same workspace, so they stay listed. Comparison follows
3104
+ * the platform's filesystem semantics (case-insensitive on Windows — a
3105
+ * pre-upgrade header may record `C:\Repo` where the current launch resolves
3106
+ * `c:\repo`). `caseInsensitive` is a parameter (not a platform read) so the
3107
+ * verifier can exercise both modes on any host. Exported for
3108
+ * scripts/verify-session-cwd.mjs.
3109
+ *
3110
+ * Boundary rule (issue #153): container directories are nobody's workspace.
3111
+ * $HOME and the Windows root forms — plain drive roots (`C:`), UNC share
3112
+ * roots (`//server/share`), and extended-length roots (`//?/C:`,
3113
+ * `//?/UNC/server/share`) — are ancestors of unrelated projects, so the
3114
+ * descendant rules below would list every session on the machine from `~`
3115
+ * (and every session on the drive/share from those roots). At these
3116
+ * boundaries, in either direction, only an exact match passes.
3117
+ */
3118
+ export function sessionCwdMatches(stateCwd, headerCwd, caseInsensitive = process.platform === 'win32') {
3119
+ const cwd = normalizeCwd(stateCwd, caseInsensitive);
3120
+ const recorded = normalizeCwd(headerCwd, caseInsensitive);
3121
+ if (recorded === '' || cwd === '')
3122
+ return false;
3123
+ const home = normalizeCwd(homeDir(), caseInsensitive);
3124
+ // Paths below arrive backslash-normalized (`\\server\share` →
3125
+ // `//server/share`, `\\?\C:\` → `//?/C:`), trailing slashes stripped.
3126
+ const isContainer = (path) => (home !== '' && path === home) ||
3127
+ /^[a-z]:$/i.test(path) || // drive root: C:
3128
+ /^\/\/[^/]+\/[^/]+$/.test(path) || // UNC share root: //server/share
3129
+ /^\/\/\?\/[a-z]:$/i.test(path) || // extended drive root: //?/C:
3130
+ /^\/\/\?\/unc\/[^/]+\/[^/]+$/i.test(path); // extended UNC root: //?/UNC/server/share
3131
+ if (isContainer(cwd) || isContainer(recorded))
3132
+ return recorded === cwd;
3133
+ return (recorded === cwd ||
3134
+ // Pre-upgrade subdirectory session of this workspace.
3135
+ recorded.startsWith(`${cwd}/`) ||
3136
+ // Resumed INTO a pre-upgrade subdirectory session (state.cwd adopted its
3137
+ // recorded subdirectory): the workspace-root sessions it belongs with
3138
+ // must stay visible, or /resume looks like it lost them for the rest of
3139
+ // the process lifetime (review leftover).
3140
+ cwd.startsWith(`${recorded}/`));
3141
+ }
2276
3142
  /** Context-bar token estimate (pi-nano-context: ~4 chars per token). */
2277
3143
  function estimateTokens(text) {
2278
3144
  return Math.ceil(text.length / 4);
@@ -2355,84 +3221,146 @@ const MENTION_MAX_DIR_ENTRIES = 200;
2355
3221
  function mentionFs(ctx) {
2356
3222
  return ctx.get('fs');
2357
3223
  }
3224
+ function mentionAttachments(ctx) {
3225
+ return ctx.get('attachments');
3226
+ }
3227
+ const MENTION_IMAGE_MEDIA_TYPES = {
3228
+ '.png': 'image/png',
3229
+ '.jpg': 'image/jpeg',
3230
+ '.jpeg': 'image/jpeg',
3231
+ '.webp': 'image/webp',
3232
+ '.gif': 'image/gif',
3233
+ };
3234
+ function mentionImageMediaType(path) {
3235
+ return MENTION_IMAGE_MEDIA_TYPES[extname(path).toLowerCase()];
3236
+ }
2358
3237
  /**
2359
3238
  * Expand a submitted text's `@` mentions (issue #15) into model-facing
2360
- * attachment blocks: each referenced file contributes its (capped) content,
2361
- * each directory a shallow listing. The typed text stays the first block
2362
- * verbatim — mentions that resolve keep their `@path` spelling in it, and
2363
- * unresolved ones stay literal everywhere. Best-effort: an unreadable or
2364
- * binary file degrades to `missing`, never a failed send.
3239
+ * attachment blocks: supported image files become durable image blocks,
3240
+ * other files contribute capped text, and directories contribute a shallow
3241
+ * listing. Reads always go through the active fs service, so provider-owned
3242
+ * workspaces keep their routing semantics. The typed text stays first and
3243
+ * verbatim. Best-effort failures degrade to `missing`, never a failed send.
2365
3244
  */
2366
- export async function expandMentions(fs, cwd, text) {
3245
+ export async function expandMentions(fs, cwd, text, attachments, stagedImages) {
2367
3246
  const blocks = [{ type: 'text', text }];
2368
3247
  const attached = [];
2369
3248
  const missing = [];
2370
3249
  const mentions = extractMentions(text);
2371
- if (!fs || mentions.length === 0)
2372
- return { blocks, attached, missing };
2373
3250
  let budget = MENTION_MAX_TOTAL_CHARS;
2374
- for (const mention of mentions) {
2375
- if (budget <= 0)
2376
- break;
2377
- // Mentions resolve against the session cwd, same as the model-facing fs
2378
- // tools; absolute paths pass through untouched.
2379
- const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
2380
- let target;
2381
- let info;
2382
- try {
2383
- target = await fs.resolve(absolute);
2384
- info = await fs.stat(target);
2385
- }
2386
- catch {
2387
- missing.push(mention.path);
2388
- continue;
2389
- }
2390
- if (info?.type === 'file') {
3251
+ let imageCount = 0;
3252
+ let imageBytes = 0;
3253
+ if (fs !== undefined) {
3254
+ for (const mention of mentions) {
3255
+ const imageMediaType = mentionImageMediaType(mention.path);
3256
+ if (budget <= 0 && imageMediaType === undefined)
3257
+ break;
3258
+ // Mentions resolve against the session cwd, same as the model-facing fs
3259
+ // tools; absolute paths pass through untouched.
3260
+ const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
3261
+ let target;
3262
+ let info;
2391
3263
  try {
2392
- const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
2393
- let content = await fs.readText(target);
2394
- let truncated = false;
2395
- if (content.length > cap) {
2396
- content = content.slice(0, cap);
2397
- truncated = true;
2398
- }
2399
- budget -= content.length;
2400
- blocks.push({
2401
- type: 'text',
2402
- text: `<attached-file path="${mention.path}">\n${content}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
2403
- });
2404
- attached.push(mention.path);
3264
+ target = await fs.resolve(absolute);
3265
+ info = await fs.stat(target);
2405
3266
  }
2406
3267
  catch {
2407
- // Binary/undecodable or unreadable — report it like a miss.
2408
3268
  missing.push(mention.path);
3269
+ continue;
2409
3270
  }
2410
- continue;
2411
- }
2412
- if (info?.type === 'directory') {
2413
- try {
2414
- const entries = await fs.listDir(target);
2415
- const listing = entries
2416
- .slice(0, MENTION_MAX_DIR_ENTRIES)
2417
- .map(entry => (entry.type === 'directory' ? `${entry.name}/` : entry.name));
2418
- if (entries.length > MENTION_MAX_DIR_ENTRIES) {
2419
- listing.push(`… (${entries.length - MENTION_MAX_DIR_ENTRIES} more)`);
3271
+ if (info?.type === 'file') {
3272
+ if (imageMediaType !== undefined && attachments !== undefined && fs.readBytes !== undefined) {
3273
+ const limits = attachments.imageLimits;
3274
+ if (!limits.mediaTypes.includes(imageMediaType) || imageCount >= limits.maxImagesPerMessage) {
3275
+ missing.push(mention.path);
3276
+ continue;
3277
+ }
3278
+ try {
3279
+ const data = await fs.readBytes(target, undefined, limits.maxImageBytes);
3280
+ if (imageBytes + data.byteLength > limits.maxMessageImageBytes) {
3281
+ missing.push(mention.path);
3282
+ continue;
3283
+ }
3284
+ const attachment = await attachments.saveImage({
3285
+ data,
3286
+ mediaType: imageMediaType,
3287
+ name: basename(target.displayPath),
3288
+ });
3289
+ blocks.push({ type: 'image', attachment });
3290
+ imageCount += 1;
3291
+ imageBytes += data.byteLength;
3292
+ attached.push(mention.path);
3293
+ }
3294
+ catch {
3295
+ missing.push(mention.path);
3296
+ }
3297
+ continue;
2420
3298
  }
2421
- const body = listing.join('\n');
2422
- budget -= body.length;
2423
- blocks.push({
2424
- type: 'text',
2425
- text: `<attached-directory path="${mention.path}">\n${body}\n</attached-directory>`,
2426
- });
2427
- attached.push(mention.path);
3299
+ try {
3300
+ const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
3301
+ let content = await fs.readText(target);
3302
+ let truncated = false;
3303
+ if (content.length > cap) {
3304
+ content = content.slice(0, cap);
3305
+ truncated = true;
3306
+ }
3307
+ budget -= content.length;
3308
+ blocks.push({
3309
+ type: 'text',
3310
+ text: `<attached-file path="${mention.path}">\n${content}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
3311
+ });
3312
+ attached.push(mention.path);
3313
+ }
3314
+ catch {
3315
+ // Binary/undecodable or unreadable — report it like a miss.
3316
+ missing.push(mention.path);
3317
+ }
3318
+ continue;
2428
3319
  }
2429
- catch {
2430
- missing.push(mention.path);
3320
+ if (info?.type === 'directory') {
3321
+ try {
3322
+ const entries = await fs.listDir(target);
3323
+ const listing = entries
3324
+ .slice(0, MENTION_MAX_DIR_ENTRIES)
3325
+ .map(entry => (entry.type === 'directory' ? `${entry.name}/` : entry.name));
3326
+ if (entries.length > MENTION_MAX_DIR_ENTRIES) {
3327
+ listing.push(`… (${entries.length - MENTION_MAX_DIR_ENTRIES} more)`);
3328
+ }
3329
+ const body = listing.join('\n');
3330
+ budget -= body.length;
3331
+ blocks.push({
3332
+ type: 'text',
3333
+ text: `<attached-directory path="${mention.path}">\n${body}\n</attached-directory>`,
3334
+ });
3335
+ attached.push(mention.path);
3336
+ }
3337
+ catch {
3338
+ missing.push(mention.path);
3339
+ }
3340
+ continue;
2431
3341
  }
2432
- continue;
3342
+ // Absent (stat → undefined) or a special file.
3343
+ missing.push(mention.path);
3344
+ }
3345
+ }
3346
+ if (attachments !== undefined && stagedImages !== undefined) {
3347
+ const limits = attachments.imageLimits;
3348
+ for (const [token, attachment] of stagedImages) {
3349
+ if (!text.includes(token))
3350
+ continue;
3351
+ // A referenced-but-dropped staged image must be loud: silently sending
3352
+ // the bare token would leave the user believing the image reached the
3353
+ // model. Reuse the missing-mention warning channel.
3354
+ if (imageCount >= limits.maxImagesPerMessage
3355
+ || imageBytes + attachment.bytes > limits.maxMessageImageBytes
3356
+ || !limits.mediaTypes.includes(attachment.mediaType)) {
3357
+ missing.push(token);
3358
+ continue;
3359
+ }
3360
+ blocks.push({ type: 'image', attachment });
3361
+ imageCount += 1;
3362
+ imageBytes += attachment.bytes;
2433
3363
  }
2434
- // Absent (stat → undefined) or a special file.
2435
- missing.push(mention.path);
2436
3364
  }
2437
3365
  return { blocks, attached, missing };
2438
3366
  }