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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (379) hide show
  1. package/README.md +74 -21
  2. package/bin/dsh-tui.js +115 -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} +184 -43
  186. package/lib/types/dsh-adapter/channel.d.ts.map +1 -0
  187. package/lib/types/{channel.js → dsh-adapter/channel.js} +972 -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} +280 -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 +977 -36
  256. package/lib/types/i18n.d.ts.map +1 -1
  257. package/lib/types/i18n.js +307 -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 +25 -7
  358. package/lib/types/approvals.d.ts.map +0 -1
  359. package/lib/types/channel.d.ts.map +0 -1
  360. package/lib/types/childStderr.d.ts.map +0 -1
  361. package/lib/types/compat/index.d.ts +0 -20
  362. package/lib/types/compat/index.d.ts.map +0 -1
  363. package/lib/types/compat/sessionLog.d.ts +0 -40
  364. package/lib/types/compat/sessionLog.d.ts.map +0 -1
  365. package/lib/types/compat/sessionLog.js +0 -226
  366. package/lib/types/invariant.d.ts.map +0 -1
  367. package/lib/types/packaged-skills.d.ts.map +0 -1
  368. package/lib/types/plugin.d.ts.map +0 -1
  369. package/lib/types/presets.d.ts.map +0 -1
  370. package/lib/types/questions.d.ts.map +0 -1
  371. /package/lib/types/{approvals.d.ts → dsh-adapter/approvals.d.ts} +0 -0
  372. /package/lib/types/{approvals.js → dsh-adapter/approvals.js} +0 -0
  373. /package/lib/types/{childStderr.d.ts → dsh-adapter/childStderr.d.ts} +0 -0
  374. /package/lib/types/{invariant.d.ts → dsh-adapter/invariant.d.ts} +0 -0
  375. /package/lib/types/{invariant.js → dsh-adapter/invariant.js} +0 -0
  376. /package/lib/types/{packaged-skills.d.ts → dsh-adapter/packaged-skills.d.ts} +0 -0
  377. /package/lib/types/{packaged-skills.js → dsh-adapter/packaged-skills.js} +0 -0
  378. /package/lib/types/{presets.d.ts → dsh-adapter/presets.d.ts} +0 -0
  379. /package/lib/types/{presets.js → dsh-adapter/presets.js} +0 -0
@@ -1,25 +1,31 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agent';
3
+ import { isUserInvocable } 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, LOCAL_COMMANDS } 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';
23
29
  const ARGS_PREVIEW_LIMIT = 160;
24
30
  const RESULT_PREVIEW_LIMIT = 240;
25
31
  /** Local `!`-command output cap (mirrors the result preview limit). */
@@ -172,6 +178,21 @@ function restoreRowFromEvent(row, event) {
172
178
  break;
173
179
  }
174
180
  }
181
+ /** Render the durable tool-result payload, including provider error details. */
182
+ function toolResultText(event) {
183
+ const block = event.data.message.content[0];
184
+ if (block === undefined || block.type !== 'tool-result')
185
+ return '';
186
+ return block.content.map(item => item.type === 'text' ? item.text : '').join('').trim();
187
+ }
188
+ function toolErrorText(event) {
189
+ const failure = event.data.error;
190
+ if (failure === undefined)
191
+ return '';
192
+ const identity = `${failure.name}: ${failure.code}`;
193
+ const detail = toolResultText(event);
194
+ return detail === '' || detail === identity ? identity : `${identity} — ${detail}`;
195
+ }
175
196
  /** Restore a folded tool row's result text from its tool/result event. */
176
197
  function restoreToolResult(row, event) {
177
198
  if (row.tool === undefined)
@@ -179,12 +200,11 @@ function restoreToolResult(row, event) {
179
200
  const failure = event.data.error;
180
201
  if (failure !== undefined) {
181
202
  row.tool.status = 'error';
182
- row.tool.errorText = `${failure.name}: ${failure.code}`;
203
+ row.tool.errorText = toolErrorText(event);
183
204
  return;
184
205
  }
185
206
  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();
207
+ const result = toolResultText(event);
188
208
  row.tool.resultFull = result || undefined;
189
209
  }
190
210
  /**
@@ -278,6 +298,14 @@ export function createChannel(ctx, initialAgent, options) {
278
298
  // command/run + command/done records). Absent the service, only the
279
299
  // built-in local commands exist.
280
300
  const commandService = ctx.get('commands');
301
+ const workspaceService = ctx.tuiWorkspaces ?? createLocalWorkspaceRuntime();
302
+ const commandTrees = ctx.get('tuiCommandTrees');
303
+ // Shift+Tab session-mode cycle: cordis.yml `modes` wins; absent/empty/
304
+ // atom-less → the built-in default/plan/full cycle (sessionModes.ts).
305
+ const { modes: sessionModes, dropped: droppedModeIds } = resolveSessionModes(options.modes);
306
+ if (droppedModeIds.length > 0) {
307
+ ctx.logger.warn(`dsh-tui: session modes ${droppedModeIds.map(id => `"${id}"`).join(', ')} declare no plan/sandbox/approval atom; dropped from the Shift+Tab cycle`);
308
+ }
281
309
  const listeners = new Set();
282
310
  /** True while a frame-aligned stream notification is pending (emitStream). */
283
311
  let streamNotifyScheduled = false;
@@ -292,7 +320,7 @@ export function createChannel(ctx, initialAgent, options) {
292
320
  return;
293
321
  contextWarned = true;
294
322
  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 });
323
+ state.notify(t('context-low-warning', { percent: percentLeft }), { color: 'warning', timeoutMs: 8000 });
296
324
  };
297
325
  /**
298
326
  * Register a submitted message as pending and notify the UI. The inbox
@@ -316,6 +344,12 @@ export function createChannel(ctx, initialAgent, options) {
316
344
  * through this chain to keep the send order FIFO.
317
345
  */
318
346
  let sendChain = Promise.resolve();
347
+ let stagedImageSequence = 0;
348
+ const stagedImages = new Map();
349
+ const clearStagedImages = () => {
350
+ stagedImages.clear();
351
+ stagedImageSequence = 0;
352
+ };
319
353
  /**
320
354
  * Expand the text's `@` mentions and deliver ONE user message: the typed
321
355
  * text stays the first content block (the transcript bubble renders it —
@@ -324,7 +358,7 @@ export function createChannel(ctx, initialAgent, options) {
324
358
  */
325
359
  const deliverUserText = (text, placement) => {
326
360
  sendChain = sendChain.then(async () => {
327
- const expansion = await expandMentions(mentionFs(ctx), state.cwd, text);
361
+ const expansion = await expandMentions(mentionFs(ctx), state.cwd, text, mentionAttachments(ctx), stagedImages);
328
362
  const message = createUserMessage({
329
363
  content: expansion.blocks,
330
364
  source: { kind: 'user' },
@@ -393,51 +427,191 @@ export function createChannel(ctx, initialAgent, options) {
393
427
  // provider default in effect.
394
428
  }
395
429
  };
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;
430
+ /** Resolve the live route's effort levels + adapter default through the
431
+ * llm runtime; 'unavailable' when the service is unmounted, 'error' when
432
+ * resolution throws (notified here). */
433
+ const resolveEfforts = async () => {
434
+ if (llmRuntime === undefined)
435
+ return 'unavailable';
407
436
  try {
408
437
  const info = await llmRuntime.resolveModelInfo(state.provider, state.model);
409
- efforts = info.reasoning?.efforts ?? [];
410
- defaultEffort = info.reasoning?.defaultEffort;
438
+ return {
439
+ efforts: info.reasoning?.efforts ?? [],
440
+ defaultEffort: info.reasoning?.defaultEffort,
441
+ };
411
442
  }
412
443
  catch (error) {
413
444
  state.notify(t('effort-read-failed', { error: error instanceof Error ? error.message : String(error) }), {
414
445
  color: 'error',
415
446
  timeoutMs: 8000,
416
447
  });
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;
448
+ return 'error';
424
449
  }
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];
450
+ };
451
+ /** Pin one validated effort level on the live route: reroutes the next
452
+ * request, persists the choice, and refreshes the StatusLine segment. */
453
+ const applyEffort = (effort) => {
430
454
  selection.current = {
431
455
  provider: state.provider,
432
456
  model: state.model,
433
- reasoningEffort: ReasoningEffortId(next.id),
457
+ reasoningEffort: ReasoningEffortId(effort.id),
434
458
  };
435
- preferredEffort = next.id;
436
- state.reasoningEffort = next.id;
437
- writeEffortPref(next.id);
438
- state.notify(t('effort-switched', { name: next.name }));
459
+ preferredEffort = effort.id;
460
+ state.reasoningEffort = effort.id;
461
+ writeEffortPref(effort.id);
462
+ state.notify(t('effort-switched', { name: effort.name }));
439
463
  state.emit();
440
464
  };
465
+ /** The live route's effort levels for the `/effort` slider; empty after
466
+ * notifying when the route is unsupported/unavailable/single-tier. */
467
+ const listEfforts = async () => {
468
+ const resolved = await resolveEfforts();
469
+ if (resolved === 'unavailable') {
470
+ state.notify(t('effort-unavailable'), { color: 'error' });
471
+ return { efforts: [], defaultEffort: undefined };
472
+ }
473
+ if (resolved === 'error')
474
+ return { efforts: [], defaultEffort: undefined };
475
+ if (resolved.efforts.length === 0) {
476
+ state.notify(t('effort-unsupported'), { color: 'warning' });
477
+ }
478
+ else if (resolved.efforts.length === 1) {
479
+ state.notify(t('effort-single-tier', { name: resolved.efforts[0].name }), { color: 'warning' });
480
+ }
481
+ return resolved;
482
+ };
483
+ /** Set one effort level by id (`/effort <id>` and the slider's live
484
+ * apply); false + a notify when the id is not offered by the route. */
485
+ const setEffort = async (id) => {
486
+ const resolved = await resolveEfforts();
487
+ if (resolved === 'unavailable') {
488
+ state.notify(t('effort-unavailable'), { color: 'error' });
489
+ return false;
490
+ }
491
+ if (resolved === 'error')
492
+ return false;
493
+ if (resolved.efforts.length === 0) {
494
+ state.notify(t('effort-unsupported'), { color: 'warning' });
495
+ return false;
496
+ }
497
+ const found = resolved.efforts.find(effort => effort.id === id);
498
+ if (!found) {
499
+ state.notify(t('effort-invalid', { id, ids: resolved.efforts.map(effort => effort.id).join(', ') }), { color: 'warning' });
500
+ return false;
501
+ }
502
+ applyEffort(found);
503
+ return true;
504
+ };
505
+ /** Run one DSH registry command (`/plan`, …) on the live agent; the text
506
+ * of its result, '' when the result is textless, undefined when the
507
+ * command is not registered, and the error message when it throws. */
508
+ const executeRegistryCommand = async (name, rawInput) => {
509
+ if (!commandService)
510
+ return undefined;
511
+ try {
512
+ const execution = await commandService.execute(agent, `/${name}${rawInput}`, new AbortController().signal);
513
+ // `undefined` = not registered; a handler error surfaces as its
514
+ // message so the user sees why the command failed.
515
+ return execution?.result.text ?? '';
516
+ }
517
+ catch (error) {
518
+ return error instanceof Error ? error.message : String(error);
519
+ }
520
+ };
521
+ // Session-mode folds: last-wins projections over the session log. The
522
+ // event types are registered by dsh-plan-mode / dsh-sandbox-policy /
523
+ // dsh-user-approval and are NOT in this package's typed SessionEvent
524
+ // union, so they are matched by name through casts — the same pattern as
525
+ // `agent-preset/selected` in renderEvent and the goal projection above.
526
+ const foldPlanActive = (events) => {
527
+ let active = false;
528
+ for (const event of events) {
529
+ if (event.type === 'plan/mode') {
530
+ active = event.data.active === true;
531
+ }
532
+ }
533
+ return active;
534
+ };
535
+ const foldSandboxMode = (events) => {
536
+ let mode;
537
+ for (const event of events) {
538
+ if (event.type === 'sandbox/mode') {
539
+ const value = event.data.mode;
540
+ if (typeof value === 'string')
541
+ mode = value;
542
+ }
543
+ }
544
+ return mode;
545
+ };
546
+ const foldApprovalPolicy = (events) => {
547
+ let policy;
548
+ for (const event of events) {
549
+ if (event.type === 'approval/policy') {
550
+ const value = event.data.policy;
551
+ if (typeof value === 'string')
552
+ policy = value;
553
+ }
554
+ }
555
+ return policy;
556
+ };
557
+ /** First configured mode whose declared atoms all match the folds;
558
+ * undeclared atoms are wildcards; no match → index 0 (the base mode).
559
+ * Matching is exact: a fresh session has no `approval/policy` event, so
560
+ * a mode declaring `approval: 'ask'` never falsely matches it. */
561
+ const deriveModeIndex = (events) => {
562
+ const index = sessionModes.findIndex(spec => (spec.plan === undefined || foldPlanActive(events) === spec.plan) &&
563
+ (spec.sandbox === undefined || foldSandboxMode(events) === spec.sandbox) &&
564
+ (spec.approval === undefined || foldApprovalPolicy(events) === spec.approval));
565
+ return index >= 0 ? index : 0;
566
+ };
567
+ /** Re-derive the current mode from the live session log (boot, every
568
+ * agent re-bind, and after mode-affecting session events). */
569
+ const refreshMode = () => {
570
+ state.modeIndex = deriveModeIndex(agent.session.events);
571
+ state.mode = sessionModes[state.modeIndex];
572
+ };
573
+ /** Apply one configured mode: each declared atom switches independently
574
+ * (plan via the registry `/plan` command; sandbox/approval via their
575
+ * durable session-log override events). A failing plan toggle aborts the
576
+ * whole switch so the session never lands in a half-applied mode. */
577
+ const applyMode = async (spec) => {
578
+ if (spec.plan !== undefined && foldPlanActive(agent.session.events) !== spec.plan) {
579
+ const text = await executeRegistryCommand('plan', spec.plan ? '' : ' off');
580
+ if (text === undefined) {
581
+ // The active preset registers no /plan.
582
+ state.notify(t('mode-plan-unavailable'), { color: 'warning' });
583
+ return;
584
+ }
585
+ }
586
+ // The durable sandbox override is one session event (dsh-sandbox-policy's
587
+ // own write path); the session/event arm picks it up immediately.
588
+ if (spec.sandbox !== undefined && foldSandboxMode(agent.session.events) !== spec.sandbox) {
589
+ ;
590
+ agent.session.append('sandbox/mode', { mode: spec.sandbox });
591
+ }
592
+ // Prefer the approval service (it narrates the switch to the model);
593
+ // the raw durable event is the fallback when it is unmounted.
594
+ if (spec.approval !== undefined && foldApprovalPolicy(agent.session.events) !== spec.approval) {
595
+ const approval = ctx.get('approval');
596
+ if (approval) {
597
+ approval.setPolicy(agent, spec.approval);
598
+ }
599
+ else {
600
+ ;
601
+ agent.session.append('approval/policy', { policy: spec.approval });
602
+ }
603
+ }
604
+ refreshMode();
605
+ state.notify(t('mode-switched', { name: modeDisplayName(state.mode) }));
606
+ state.emit();
607
+ };
608
+ /** Shift+Tab: advance to the next configured session mode. Cycling starts
609
+ * from the mode DERIVED from the session log (never a stored index), so
610
+ * manual `/plan` use can never desync the cycle. */
611
+ const cycleMode = async () => {
612
+ const index = deriveModeIndex(agent.session.events);
613
+ await applyMode(sessionModes[(index + 1) % sessionModes.length]);
614
+ };
441
615
  const state = {
442
616
  version: 0,
443
617
  rows: [],
@@ -448,6 +622,7 @@ export function createChannel(ctx, initialAgent, options) {
448
622
  provider: options.provider,
449
623
  tokens: { input: 0, output: 0 },
450
624
  cwd: options.cwd,
625
+ displayCwd: workspaceService.describe(options.cwd).description ?? options.cwd,
451
626
  gitBranch: undefined,
452
627
  working: false,
453
628
  spinnerMode: 'requesting',
@@ -457,9 +632,13 @@ export function createChannel(ctx, initialAgent, options) {
457
632
  lastUserText: '',
458
633
  notifications: [],
459
634
  contextWindow: undefined,
460
- // Explicit cordis.yml `effort` wins; otherwise the persisted Shift+Tab
635
+ // Explicit cordis.yml `effort` wins; otherwise the persisted /effort
461
636
  // choice; the first request/header event re-asserts the adapter's truth.
462
637
  reasoningEffort: options.effort ?? readEffortPref(),
638
+ // Session-mode seed; the first refreshMode() (bindAgent) re-derives it
639
+ // from the session log, so a resumed session lands on its recorded mode.
640
+ mode: sessionModes[0],
641
+ modeIndex: 0,
463
642
  workingActivity: undefined,
464
643
  activityFrames: options.activityFrames,
465
644
  activityEnabled: options.activity !== false,
@@ -470,6 +649,29 @@ export function createChannel(ctx, initialAgent, options) {
470
649
  loadedContext: undefined,
471
650
  pending: [],
472
651
  commandList: LOCAL_COMMANDS,
652
+ commandCompletions(input) {
653
+ return completeCommands(input, state.commandList, (path) => {
654
+ if (path.length === 1 && path[0] === 'workspace') {
655
+ const builtins = [
656
+ { name: 'resume', description: 'Switch to another workspace', descriptionKey: 'cmd-desc-workspace-resume' },
657
+ { name: 'rename', description: 'Rename the current workspace', descriptionKey: 'cmd-desc-workspace-rename' },
658
+ { name: 'open', description: 'Open a path or workspace URI', descriptionKey: 'cmd-desc-workspace-open' },
659
+ ];
660
+ const reserved = new Set(builtins.map(command => command.name));
661
+ return [
662
+ ...builtins,
663
+ ...workspaceService.commands()
664
+ .filter(command => !reserved.has(command.name.toLowerCase()))
665
+ .map(command => ({
666
+ name: command.name,
667
+ aliases: command.aliases,
668
+ description: command.description,
669
+ })),
670
+ ];
671
+ }
672
+ return commandTrees?.children(path) ?? [];
673
+ });
674
+ },
473
675
  lastUsage: undefined,
474
676
  tps: undefined,
475
677
  tpsSamples: [],
@@ -522,6 +724,30 @@ export function createChannel(ctx, initialAgent, options) {
522
724
  state.emit();
523
725
  return restored;
524
726
  },
727
+ async stageImage(input) {
728
+ const attachments = mentionAttachments(ctx);
729
+ if (attachments === undefined)
730
+ throw new Error('image attachments are unavailable in this profile');
731
+ if (!attachments.imageLimits.mediaTypes.includes(input.mediaType)) {
732
+ throw new Error(`${input.mediaType} images are not accepted by this profile`);
733
+ }
734
+ if (input.data.byteLength > attachments.imageLimits.maxImageBytes) {
735
+ throw new Error(`image exceeds this profile's per-image size limit`);
736
+ }
737
+ const attachment = await attachments.saveImage(input);
738
+ stagedImageSequence += 1;
739
+ const token = `[Image #${stagedImageSequence}]`;
740
+ stagedImages.set(token, attachment);
741
+ // References are content-addressed and durable. This map only connects
742
+ // editable prompt placeholders to them; cap it to bound a long TUI run.
743
+ while (stagedImages.size > 128) {
744
+ const oldest = stagedImages.keys().next().value;
745
+ if (oldest === undefined)
746
+ break;
747
+ stagedImages.delete(oldest);
748
+ }
749
+ return token;
750
+ },
525
751
  submit(text) {
526
752
  const trimmed = text.trim();
527
753
  if (!trimmed)
@@ -619,7 +845,7 @@ export function createChannel(ctx, initialAgent, options) {
619
845
  const sessions = ctx.get('sessions');
620
846
  const agents = ctx.get('agents');
621
847
  if (!sessions || !agents) {
622
- state.notify('Rewind unavailable — session services not loaded', { color: 'error' });
848
+ state.notify(t('rewind-unavailable'), { color: 'error' });
623
849
  return null;
624
850
  }
625
851
  // Stop a running turn first and WAIT for its turn/end to land — fork
@@ -632,7 +858,7 @@ export function createChannel(ctx, initialAgent, options) {
632
858
  if (wasWorking) {
633
859
  const turnSettled = await waitForTurnEnd(agent.session, cancelSeq, 30000);
634
860
  if (!turnSettled) {
635
- state.notify('Cannot rewind — the turn is still settling, try again in a moment', { color: 'error' });
861
+ state.notify(t('rewind-settling'), { color: 'error' });
636
862
  return null;
637
863
  }
638
864
  }
@@ -669,7 +895,7 @@ export function createChannel(ctx, initialAgent, options) {
669
895
  }
670
896
  catch (error) {
671
897
  const message = error instanceof Error ? error.message : String(error);
672
- state.notify(`Cannot rewind to this point · ${message}`, { color: 'error' });
898
+ state.notify(t('rewind-fork-failed', { err: message }), { color: 'error' });
673
899
  return null;
674
900
  }
675
901
  let handle;
@@ -684,7 +910,7 @@ export function createChannel(ctx, initialAgent, options) {
684
910
  sessionId: childId,
685
911
  seed,
686
912
  meta: {
687
- cwd: options.cwd,
913
+ cwd: state.cwd,
688
914
  parentSession: agent.session.id,
689
915
  seedLength: seed.length,
690
916
  ...(rewindComposed.agentPreset === undefined
@@ -696,9 +922,15 @@ export function createChannel(ctx, initialAgent, options) {
696
922
  });
697
923
  }
698
924
  catch {
699
- state.notify('Rewind failed — could not create the replacement session', { color: 'error' });
925
+ state.notify(t('rewind-create-failed'), { color: 'error' });
700
926
  return null;
701
927
  }
928
+ try {
929
+ await attachSessionToWorkspace(ctx, state.cwd, childId);
930
+ }
931
+ catch (error) {
932
+ state.notify(t('rewind-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
933
+ }
702
934
  // Replay the forked history into a fresh transcript (tokens/spinner
703
935
  // counters land back at the rewind point, matching the fork).
704
936
  streaming = undefined;
@@ -709,6 +941,11 @@ export function createChannel(ctx, initialAgent, options) {
709
941
  // Goal/todo/title are session-scoped; the replay re-derives them for
710
942
  // the session being entered (or leaves them empty).
711
943
  state.todos = [];
944
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
945
+ // swap must drop their previews or they linger forever (unretirable —
946
+ // retire events are filtered to the new agent, unwithdrawable — the
947
+ // new inbox never heard of them).
948
+ state.pending = [];
712
949
  state.goal = undefined;
713
950
  state.sessionTitle = '';
714
951
  state.tokens = { input: 0, output: 0 };
@@ -749,17 +986,24 @@ export function createChannel(ctx, initialAgent, options) {
749
986
  async resumeTo(sessionId) {
750
987
  // Switch the live agent to a persisted session: /resume picker Enter
751
988
  // loads the history immediately (the `--resume` launcher path keeps
752
- // resolving through DSH_CC_RESUME_SESSION at boot).
989
+ // resolving through DSH_TUI_RESUME_SESSION at boot).
753
990
  if (state.working) {
754
- state.notify('Cannot resume while a turn is running', { color: 'warning' });
991
+ state.notify(t('resume-while-working'), { color: 'warning' });
755
992
  return false;
756
993
  }
757
994
  const agents = ctx.get('agents');
758
995
  if (!agents) {
759
- state.notify('Resume unavailable — agents service not loaded', { color: 'error' });
996
+ state.notify(t('resume-unavailable'), { color: 'error' });
760
997
  return false;
761
998
  }
762
999
  let handle;
1000
+ // Compat boundary: register vouched-for legacy event types (e.g.
1001
+ // activity/status from pre-#143 logs) in every reachable dsh-session
1002
+ // copy before ANY strict read path (preset lookup below, then the
1003
+ // harness seed validation) loads the target — the plugin's #119
1004
+ // registration never ran in processes where it is unmounted (issue
1005
+ // #153). In-process only: the shared log is never rewritten.
1006
+ ensureLegacySessionEventTypes();
763
1007
  // The target session's own preset (from its persisted log) — never the
764
1008
  // current preference: a resume re-enters the composition its history
765
1009
  // was produced under. Same rule for the route: only an explicit
@@ -773,9 +1017,6 @@ export function createChannel(ctx, initialAgent, options) {
773
1017
  model: options.configuredModel,
774
1018
  });
775
1019
  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
1020
  handle = await agents.resume({
780
1021
  resumeSessionId: SessionId(sessionId),
781
1022
  agentOptions: { provider: resumeRoute?.provider, model: resumeRoute?.model },
@@ -784,9 +1025,18 @@ export function createChannel(ctx, initialAgent, options) {
784
1025
  }
785
1026
  catch (error) {
786
1027
  const message = error instanceof Error ? error.message : String(error);
787
- state.notify(`Resume failed · ${message}`, { color: 'error', timeoutMs: 8000 });
1028
+ state.notify(t('resume-failed', { err: message }), { color: 'error', timeoutMs: 8000 });
788
1029
  return false;
789
1030
  }
1031
+ try {
1032
+ // `/resume` is an explicit adoption of this persisted conversation.
1033
+ // This also repairs sessions created by TUI versions that predate the
1034
+ // separate workspace ownership ledger.
1035
+ await attachSessionToWorkspace(ctx, handle.agent.session.header.cwd ?? state.cwd, SessionId(sessionId));
1036
+ }
1037
+ catch (error) {
1038
+ state.notify(t('resume-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1039
+ }
790
1040
  // Replay the persisted history into a fresh transcript (same reset as
791
1041
  // rewindTo, plus the context window which the replay re-derives).
792
1042
  streaming = undefined;
@@ -797,6 +1047,11 @@ export function createChannel(ctx, initialAgent, options) {
797
1047
  // Goal/todo/title are session-scoped; the replay re-derives them for
798
1048
  // the session being entered (or leaves them empty).
799
1049
  state.todos = [];
1050
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
1051
+ // swap must drop their previews or they linger forever (unretirable —
1052
+ // retire events are filtered to the new agent, unwithdrawable — the
1053
+ // new inbox never heard of them).
1054
+ state.pending = [];
800
1055
  state.goal = undefined;
801
1056
  state.sessionTitle = '';
802
1057
  state.tokens = { input: 0, output: 0 };
@@ -807,6 +1062,15 @@ export function createChannel(ctx, initialAgent, options) {
807
1062
  state.spinnerMode = 'requesting';
808
1063
  state.status = handle.agent.status;
809
1064
  state.agentId = handle.agent.id;
1065
+ // Adopt the resumed session's persisted cwd (issue #96): pre-upgrade
1066
+ // sessions recorded the LAUNCH directory (often a repo subdirectory),
1067
+ // so keeping the freshly resolved root would split @ expansion / file
1068
+ // completion (state.cwd) from the agent's own workspace record — and
1069
+ // drop the session back out of the /resume filter. The branch
1070
+ // breadcrumb follows the adopted cwd.
1071
+ state.cwd = handle.agent.session.header.cwd ?? state.cwd;
1072
+ state.displayCwd = workspaceService.describe(state.cwd).description ?? state.cwd;
1073
+ refreshGitBranch();
810
1074
  state.agentPreset = resumeComposed.agentPreset;
811
1075
  // Status-line route follows the resumed session (review feedback): the
812
1076
  // route it actually continues on — a complete cordis.yml pin, else the
@@ -845,6 +1109,7 @@ export function createChannel(ctx, initialAgent, options) {
845
1109
  touchSession(sessionId);
846
1110
  state.emit();
847
1111
  void oldHandle?.dispose().catch(() => { });
1112
+ clearStagedImages();
848
1113
  return true;
849
1114
  },
850
1115
  async newSession() {
@@ -852,14 +1117,14 @@ export function createChannel(ctx, initialAgent, options) {
852
1117
  // transcript reset, the `--resume` marker forgotten (the old session
853
1118
  // stays persisted for /resume). Same reset shape as rewindTo/resumeTo.
854
1119
  if (state.working) {
855
- state.notify('Cannot start a new session while a turn is running', {
1120
+ state.notify(t('new-session-while-working'), {
856
1121
  color: 'warning',
857
1122
  });
858
1123
  return false;
859
1124
  }
860
1125
  const agents = ctx.get('agents');
861
1126
  if (!agents) {
862
- state.notify('New session unavailable — agents service not loaded', {
1127
+ state.notify(t('new-session-unavailable'), {
863
1128
  color: 'error',
864
1129
  });
865
1130
  return false;
@@ -893,7 +1158,7 @@ export function createChannel(ctx, initialAgent, options) {
893
1158
  handle = await agents.create({
894
1159
  sessionId,
895
1160
  meta: {
896
- cwd: options.cwd,
1161
+ cwd: state.cwd,
897
1162
  ...(newComposed.agentPreset === undefined
898
1163
  ? {}
899
1164
  : { agentPreset: newComposed.agentPreset }),
@@ -904,12 +1169,18 @@ export function createChannel(ctx, initialAgent, options) {
904
1169
  }
905
1170
  catch (error) {
906
1171
  const message = error instanceof Error ? error.message : String(error);
907
- state.notify(`New session failed · ${message}`, {
1172
+ state.notify(t('new-session-failed', { err: message }), {
908
1173
  color: 'error',
909
1174
  timeoutMs: 8000,
910
1175
  });
911
1176
  return false;
912
1177
  }
1178
+ try {
1179
+ await attachSessionToWorkspace(ctx, state.cwd, sessionId);
1180
+ }
1181
+ catch (error) {
1182
+ state.notify(t('new-session-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1183
+ }
913
1184
  streaming = undefined;
914
1185
  reasoning = undefined;
915
1186
  toolCards.clear();
@@ -918,6 +1189,11 @@ export function createChannel(ctx, initialAgent, options) {
918
1189
  // Goal/todo/title are session-scoped; the replay re-derives them for
919
1190
  // the session being entered (or leaves them empty).
920
1191
  state.todos = [];
1192
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
1193
+ // swap must drop their previews or they linger forever (unretirable —
1194
+ // retire events are filtered to the new agent, unwithdrawable — the
1195
+ // new inbox never heard of them).
1196
+ state.pending = [];
921
1197
  state.goal = undefined;
922
1198
  state.sessionTitle = '';
923
1199
  state.tokens = { input: 0, output: 0 };
@@ -952,17 +1228,75 @@ export function createChannel(ctx, initialAgent, options) {
952
1228
  clearResumeTarget();
953
1229
  // The brand-new session becomes the most recently used.
954
1230
  touchSession(handle.agent.id);
955
- state.emit();
956
1231
  void oldHandle?.dispose().catch(() => { });
1232
+ clearStagedImages();
957
1233
  return true;
958
1234
  },
1235
+ listWorkspaces() {
1236
+ return workspaceService.list(state.cwd);
1237
+ },
1238
+ resolveWorkspace(uri) {
1239
+ return workspaceService.resolve(uri, state.cwd);
1240
+ },
1241
+ async switchWorkspace(target) {
1242
+ if (state.working) {
1243
+ state.notify(t('workspace-switch-working'), { color: 'warning' });
1244
+ return false;
1245
+ }
1246
+ // Local targets must exist and be directories — creating a session in
1247
+ // a typo'd cwd "succeeds" and then every file tool errors per call.
1248
+ if (target.kind === 'local') {
1249
+ try {
1250
+ if (!statSync(target.cwd).isDirectory())
1251
+ throw new Error('not a directory');
1252
+ }
1253
+ catch {
1254
+ state.notify(t('workspace-open-invalid', { target: target.label }), { color: 'error', timeoutMs: 8000 });
1255
+ return false;
1256
+ }
1257
+ }
1258
+ const previousCwd = state.cwd;
1259
+ const previousDisplay = state.displayCwd;
1260
+ state.cwd = target.cwd;
1261
+ state.displayCwd = target.description ?? target.uri;
1262
+ const switched = await state.newSession();
1263
+ if (!switched) {
1264
+ state.cwd = previousCwd;
1265
+ state.displayCwd = previousDisplay;
1266
+ return false;
1267
+ }
1268
+ // The breadcrumb follows the adopted cwd, same as /resume (#96).
1269
+ refreshGitBranch();
1270
+ state.notify(t('workspace-switched', { target: target.label }));
1271
+ state.emit();
1272
+ return true;
1273
+ },
1274
+ async renameWorkspace(title) {
1275
+ try {
1276
+ const renamed = await workspaceService.rename(state.cwd, title);
1277
+ state.displayCwd = renamed.description ?? renamed.uri;
1278
+ state.notify(t('workspace-renamed', { title: renamed.label }));
1279
+ state.emit();
1280
+ return true;
1281
+ }
1282
+ catch (error) {
1283
+ state.notify(t('workspace-rename-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
1284
+ return false;
1285
+ }
1286
+ },
1287
+ workspaceCommands() {
1288
+ return workspaceService.commands();
1289
+ },
1290
+ runWorkspaceCommand(name, input) {
1291
+ return workspaceService.runCommand(name, input, state.cwd);
1292
+ },
959
1293
  async switchModel(provider, model) {
960
1294
  // `/model` picker Enter — switch the live model by forking the
961
1295
  // conversation at its current end and continuing with a new agent
962
1296
  // routed to the chosen model. Same reset shape as rewindTo/resumeTo;
963
1297
  // the history replays unchanged, only the request model changes.
964
1298
  if (state.working) {
965
- state.notify('Cannot switch models while a turn is running', {
1299
+ state.notify(t('model-switch-while-working'), {
966
1300
  color: 'warning',
967
1301
  });
968
1302
  return false;
@@ -970,7 +1304,7 @@ export function createChannel(ctx, initialAgent, options) {
970
1304
  const sessions = ctx.get('sessions');
971
1305
  const agents = ctx.get('agents');
972
1306
  if (!sessions || !agents) {
973
- state.notify('Model switch unavailable — session services not loaded', {
1307
+ state.notify(t('model-switch-unavailable'), {
974
1308
  color: 'error',
975
1309
  });
976
1310
  return false;
@@ -982,7 +1316,7 @@ export function createChannel(ctx, initialAgent, options) {
982
1316
  }
983
1317
  catch (error) {
984
1318
  const message = error instanceof Error ? error.message : String(error);
985
- state.notify(`Cannot switch models · ${message}`, { color: 'error' });
1319
+ state.notify(t('model-switch-fork-failed', { err: message }), { color: 'error' });
986
1320
  return false;
987
1321
  }
988
1322
  const childId = SessionId(randomUUID());
@@ -995,7 +1329,7 @@ export function createChannel(ctx, initialAgent, options) {
995
1329
  sessionId: childId,
996
1330
  seed,
997
1331
  meta: {
998
- cwd: options.cwd,
1332
+ cwd: state.cwd,
999
1333
  parentSession: agent.session.id,
1000
1334
  seedLength: seed.length,
1001
1335
  ...(modelComposed.agentPreset === undefined
@@ -1008,9 +1342,15 @@ export function createChannel(ctx, initialAgent, options) {
1008
1342
  }
1009
1343
  catch (error) {
1010
1344
  const message = error instanceof Error ? error.message : String(error);
1011
- state.notify(`Model switch failed · ${message}`, { color: 'error', timeoutMs: 8000 });
1345
+ state.notify(t('model-switch-failed', { err: message }), { color: 'error', timeoutMs: 8000 });
1012
1346
  return false;
1013
1347
  }
1348
+ try {
1349
+ await attachSessionToWorkspace(ctx, state.cwd, childId);
1350
+ }
1351
+ catch (error) {
1352
+ state.notify(t('model-switch-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1353
+ }
1014
1354
  streaming = undefined;
1015
1355
  reasoning = undefined;
1016
1356
  toolCards.clear();
@@ -1019,6 +1359,11 @@ export function createChannel(ctx, initialAgent, options) {
1019
1359
  // Goal/todo/title are session-scoped; the replay re-derives them for
1020
1360
  // the session being entered (or leaves them empty).
1021
1361
  state.todos = [];
1362
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
1363
+ // swap must drop their previews or they linger forever (unretirable —
1364
+ // retire events are filtered to the new agent, unwithdrawable — the
1365
+ // new inbox never heard of them).
1366
+ state.pending = [];
1022
1367
  state.goal = undefined;
1023
1368
  state.sessionTitle = '';
1024
1369
  state.tokens = { input: 0, output: 0 };
@@ -1058,7 +1403,7 @@ export function createChannel(ctx, initialAgent, options) {
1058
1403
  state.emit();
1059
1404
  void oldHandle?.dispose().catch(() => { });
1060
1405
  // 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
1406
+ // contract as /preset and /effort; issues #14/#30). A failed
1062
1407
  // write keeps the live switch but warns it will not survive a restart.
1063
1408
  if (!writeModelPref(provider, model)) {
1064
1409
  state.notify(t('model-pref-write-failed'), {
@@ -1067,7 +1412,9 @@ export function createChannel(ctx, initialAgent, options) {
1067
1412
  }
1068
1413
  return true;
1069
1414
  },
1070
- cycleEffort,
1415
+ listEfforts,
1416
+ setEffort,
1417
+ cycleMode,
1071
1418
  clear() {
1072
1419
  state.rows.length = 0;
1073
1420
  nextRowId = 0;
@@ -1210,6 +1557,105 @@ export function createChannel(ctx, initialAgent, options) {
1210
1557
  return Promise.all(providers.map(provider => llm.listModels(provider.id).catch(() => [])))
1211
1558
  .then(lists => lists.flat());
1212
1559
  },
1560
+ providerSetup() {
1561
+ // The `/provider` wizard's runtime surface, over the dsh-base seams:
1562
+ // settings (profile persistence), credentials (key storage) and the
1563
+ // llm runtime's configurable-provider directory + model discovery.
1564
+ // Structurally typed like the other optional seams in this file.
1565
+ const llm = ctx.get('llm');
1566
+ const settings = ctx.get('settings');
1567
+ const credentials = ctx.get('credentials');
1568
+ // Without dsh-llm-pi-ai there is no adapter watching the settings
1569
+ // section, so a written profile would never activate a route. The
1570
+ // adapter registers its `llm-pi-ai` settings namespace at mount, which
1571
+ // is the rc.6-observable mount signal (the newer
1572
+ // `listModelDiscoveryNamespaces()` does not exist in rc.6).
1573
+ if (!llm || !settings || !credentials
1574
+ || !settings.describe().some(descriptor => descriptor.ns === 'llm-pi-ai')) {
1575
+ return undefined;
1576
+ }
1577
+ const revision = () => settings.describe().find(descriptor => descriptor.ns === 'llm-pi-ai')?.revision;
1578
+ return {
1579
+ listCatalogProviders() {
1580
+ // declared === true marks routes the adapter knows only because a
1581
+ // stored profile names them (user-added); the rest are activatable
1582
+ // catalog routes.
1583
+ return llm.listConfigurableProviders()
1584
+ .filter(entry => entry.settingsNs === 'llm-pi-ai' && entry.declared !== true)
1585
+ .map(entry => ({ provider: entry.provider, displayName: entry.displayName }));
1586
+ },
1587
+ routeExists(route) {
1588
+ const section = settings.get('llm-pi-ai');
1589
+ return section?.providers !== undefined && route in section.providers;
1590
+ },
1591
+ discoverModels(request) {
1592
+ return llm.discoverModels('llm-pi-ai', request);
1593
+ },
1594
+ envShadows(ref) {
1595
+ return process.env[ref] !== undefined;
1596
+ },
1597
+ async readCredential(ref) {
1598
+ const resolved = await credentials.resolve(ref);
1599
+ return resolved?.value;
1600
+ },
1601
+ writeCredential(ref, value) {
1602
+ return credentials.set(ref, value);
1603
+ },
1604
+ removeCredential(ref) {
1605
+ return credentials.unset(ref);
1606
+ },
1607
+ async writeProfile(route, profile) {
1608
+ const ops = [{ op: 'set', path: ['providers', route], value: profile }];
1609
+ try {
1610
+ await settings.mutate('llm-pi-ai', ops, revision());
1611
+ }
1612
+ catch (error) {
1613
+ // One retry on a stale-revision conflict (a concurrent write
1614
+ // landed between describe and mutate); anything else propagates
1615
+ // so the wizard can report and roll back the credential.
1616
+ const code = error?.code;
1617
+ if (code !== 'SETTINGS_CONFLICT')
1618
+ throw error;
1619
+ await settings.mutate('llm-pi-ai', ops, revision());
1620
+ }
1621
+ },
1622
+ };
1623
+ },
1624
+ async sideQuestion(question, options) {
1625
+ // CC /btw:无工具单轮辅助调用,重放 deriveMessages() 前缀 + 一条
1626
+ // 包装问题。tools 永不传(侧问无工具是核心语义);usage 不回收
1627
+ // (skipCacheWrite 同义——答案不进主上下文也不进 token 计数)。
1628
+ const llm = ctx.get('llm');
1629
+ if (!llm)
1630
+ return { answer: null, error: t('btw-llm-unavailable') };
1631
+ const header = agent.session.requestHeader();
1632
+ const config = header?.config;
1633
+ const messages = [
1634
+ ...agent.session.deriveMessages(),
1635
+ createUserMessage({
1636
+ content: [{ type: 'text', text: wrapSideQuestion(question) }],
1637
+ source: { kind: 'plugin', plugin: 'dsh-tui/btw' },
1638
+ }),
1639
+ ];
1640
+ const request = {
1641
+ provider: config?.provider ?? state.provider,
1642
+ model: config?.model ?? state.model,
1643
+ messages,
1644
+ ...(header?.system !== undefined && { system: header.system }),
1645
+ ...(config?.reasoningEffort !== undefined && { reasoningEffort: config.reasoningEffort }),
1646
+ ...(config?.temperature !== undefined && { temperature: config.temperature }),
1647
+ ...(config?.maxTokens !== undefined && { maxTokens: config.maxTokens }),
1648
+ ...(config?.stop !== undefined && { stop: [...config.stop] }),
1649
+ sessionId: agent.session.id,
1650
+ ...(options?.signal && { signal: options.signal }),
1651
+ };
1652
+ return runSideQuestion({
1653
+ stream: llm.stream.bind(llm),
1654
+ options: request,
1655
+ onText: options?.onText,
1656
+ signal: options?.signal,
1657
+ });
1658
+ },
1213
1659
  listFiles() {
1214
1660
  const fs = ctx.get('fs');
1215
1661
  return listFilesDeep(fs, state.cwd);
@@ -1224,8 +1670,7 @@ export function createChannel(ctx, initialAgent, options) {
1224
1670
  const headers = await persistence.list();
1225
1671
  // 按工作目录隔离(Claude Code 的项目维度):/resume 只列出本会话
1226
1672
  // 目录启动的会话,别的项目的会话不出现在选择器里。
1227
- const cwd = state.cwd.replace(/\/+$/, '');
1228
- const local = headers.filter(header => (header.cwd ?? '').replace(/\/+$/, '') === cwd);
1673
+ const local = headers.filter(header => sessionCwdMatches(state.cwd, header.cwd ?? ''));
1229
1674
  // MRU ordering: DSH headers carry only createdAt, so dsh-tui keeps its
1230
1675
  // own last-used timestamps (touchSession on resume/submit/new) and
1231
1676
  // falls back to createdAt for sessions never touched in this install.
@@ -1247,8 +1692,8 @@ export function createChannel(ctx, initialAgent, options) {
1247
1692
  // useful label. Read via the tolerant compat log reader instead of
1248
1693
  // persistence.load: the backend validates every event against
1249
1694
  // 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
1695
+ // an unregistered third-party type, which would otherwise leave the
1696
+ // affected session titled with the cwd
1252
1697
  // basename. An unreadable log keeps the basename fallback.
1253
1698
  const empty = new Set();
1254
1699
  for (const record of records.slice(0, SESSION_TITLE_DEPTH)) {
@@ -1284,6 +1729,41 @@ export function createChannel(ctx, initialAgent, options) {
1284
1729
  state.sessionTitle = title;
1285
1730
  state.emit();
1286
1731
  },
1732
+ async deleteSession(sessionId) {
1733
+ // The live session's log is still being appended by this process —
1734
+ // deleting it from under the writer is never offered in the picker
1735
+ // (the current session is filtered out), so refuse it here too.
1736
+ if (sessionId === agent.session.id)
1737
+ return false;
1738
+ if (deleteSessionLog(sessionId) !== 'deleted')
1739
+ return false;
1740
+ forgetSession(sessionId);
1741
+ // A resume marker naming the deleted session would make the next
1742
+ // `dsh-tui --resume` launch target a log that no longer exists.
1743
+ if (readResumeTarget() === sessionId)
1744
+ clearResumeTarget();
1745
+ return true;
1746
+ },
1747
+ async renameSessionTo(sessionId, title) {
1748
+ if (sessionId === agent.session.id) {
1749
+ // The live session renames through session.append so the firehose
1750
+ // updates the status line right away (same as /rename).
1751
+ agent.session.append('session/title', { title });
1752
+ state.sessionTitle = title;
1753
+ state.emit();
1754
+ return true;
1755
+ }
1756
+ if (appendSessionTitle(sessionId, title) !== 'appended')
1757
+ return false;
1758
+ // listSessions resolves persisted titles only for the MRU top
1759
+ // SESSION_TITLE_DEPTH; a rename does not change MRU by itself, so a
1760
+ // session beyond the window would keep showing the cwd-basename
1761
+ // fallback (in the next picker AND after restart) even though the
1762
+ // title event is durable. A rename IS user interaction with the
1763
+ // session — touching it pulls it into the title window.
1764
+ touchSession(sessionId);
1765
+ return true;
1766
+ },
1287
1767
  compact() {
1288
1768
  // DSH compaction service key: `ctx.compaction` (dsh-compaction's
1289
1769
  // CompactionEngine; dsh-compaction-basic provides it in the example
@@ -1292,38 +1772,28 @@ export function createChannel(ctx, initialAgent, options) {
1292
1772
  // scope chain first (minimal composes NO compaction: stays unavailable).
1293
1773
  const compactService = serviceForAgent(ctx, agent, 'compaction');
1294
1774
  if (!compactService) {
1295
- state.notify('Compaction unavailable · no compaction service in this leaf', {
1775
+ state.notify(t('compact-unavailable'), {
1296
1776
  color: 'warning',
1297
1777
  });
1298
1778
  return;
1299
1779
  }
1300
1780
  if (state.working) {
1301
- state.notify('Cannot compact while a turn is running', { color: 'warning' });
1781
+ state.notify(t('compact-while-working'), { color: 'warning' });
1302
1782
  return;
1303
1783
  }
1304
1784
  const signal = new AbortController().signal;
1305
- state.notify('Compacting conversation…');
1785
+ state.notify(t('compact-working'));
1306
1786
  void compactService
1307
1787
  .compactNow(agent, signal)
1308
1788
  .then((result) => {
1309
- state.notify(result ? 'Conversation compacted' : 'Nothing to compact');
1789
+ state.notify(result ? t('compact-done') : t('compact-nothing'));
1310
1790
  })
1311
1791
  .catch((error) => {
1312
- state.notify(`Compaction failed · ${error instanceof Error ? error.message : String(error)}`, { color: 'error', timeoutMs: 8000 });
1792
+ state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
1313
1793
  });
1314
1794
  },
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
- }
1795
+ runExternalCommand(name, rawInput) {
1796
+ return executeRegistryCommand(name, rawInput);
1327
1797
  },
1328
1798
  pushLocal(title, lines) {
1329
1799
  state.rows.push({ id: nextRowId++, kind: 'local', text: title });
@@ -1464,16 +1934,24 @@ export function createChannel(ctx, initialAgent, options) {
1464
1934
  lines.push(t('doctor-cwd', { cwd: state.cwd }));
1465
1935
  lines.push(t('doctor-context-window', { window: state.contextWindow ?? t('doctor-unknown') }));
1466
1936
  lines.push(`${t('doctor-session', { id: state.agentId })}${state.sessionTitle ? ' · ' + state.sessionTitle : ''}`);
1467
- const userHome = process.env.USERPROFILE ?? homedir();
1937
+ const userHome = homeDir();
1468
1938
  const configCandidates = [
1469
- join(userHome, '.dsh-cc/cordis.yml'),
1939
+ join(userHome, '.dsh-tui/cordis.yml'),
1470
1940
  join(userHome, '.dsh/profiles/dsh-tui/cordis.patch.yml'),
1471
1941
  ];
1472
1942
  for (const candidate of configCandidates) {
1473
1943
  lines.push(`${t('doctor-config', { candidate, state: existsSync(candidate) ? '✓' : t('doctor-config-missing') })}`);
1474
1944
  }
1475
- const sessionsDir = join(userHome, '.dsh-cc/sessions');
1476
- lines.push(`${t('doctor-storage', { dir: sessionsDir, state: existsSync(sessionsDir) ? '✓' : t('doctor-storage-uninit') })}`);
1945
+ // Session store candidates mirror the compat layer (sessionsRoots):
1946
+ // the active root depends on the composition (bare cordis.yml →
1947
+ // legacy ~/.dsh-tui, profile → $DSH_HOME/sessions), so list every
1948
+ // candidate with its own state instead of hardcoding one.
1949
+ for (const dir of sessionsRoots()) {
1950
+ lines.push(`${t('doctor-storage', { dir, state: existsSync(dir) ? '✓' : t('doctor-storage-uninit') })}`);
1951
+ }
1952
+ if (existsSync(LEGACY_DATA_DIR)) {
1953
+ lines.push(t('doctor-legacy-dir'));
1954
+ }
1477
1955
  return lines;
1478
1956
  },
1479
1957
  async listSubagents() {
@@ -1495,6 +1973,11 @@ export function createChannel(ctx, initialAgent, options) {
1495
1973
  return [t('subagent-query-failed', { err: error instanceof Error ? error.message : String(error) })];
1496
1974
  }
1497
1975
  },
1976
+ traceEvents() {
1977
+ // Immutable per-append snapshot (dsh-session caches the frozen array);
1978
+ // reads follow agent swaps (/resume /rewind /new) automatically.
1979
+ return agent.session.events;
1980
+ },
1498
1981
  };
1499
1982
  /**
1500
1983
  * Assemble the context a fresh conversation for the live agent will load,
@@ -1536,10 +2019,20 @@ export function createChannel(ctx, initialAgent, options) {
1536
2019
  tools.push({ name: tool.name, description: tool.description ?? '' });
1537
2020
  }
1538
2021
  }
1539
- const discovered = await discoverBaselineInstructionFiles({ cwd: options.cwd });
2022
+ const renderedInstructions = await loadBaselineInstructions({
2023
+ cwd: state.cwd,
2024
+ maxBytes: 1024 * 1024,
2025
+ maxSourceBytes: 1024 * 1024,
2026
+ }, ctx.get('fs'));
1540
2027
  if (target !== agent)
1541
2028
  return;
1542
- files.push(...discovered.map(file => ({ displayPath: file.displayPath })));
2029
+ const instructionSources = renderedInstructions;
2030
+ const instructionPaths = new Set([
2031
+ ...(instructionSources?.represented ?? []).map(file => file.displayPath),
2032
+ ...(renderedInstructions?.omitted ?? []).map(file => file.displayPath),
2033
+ ...(renderedInstructions?.truncated ?? []).map(file => file.displayPath),
2034
+ ]);
2035
+ files.push(...[...instructionPaths].map(displayPath => ({ displayPath })));
1543
2036
  // The skills registry is host-plane but scope-layered: preset rows
1544
2037
  // (skill-filesystem) register into the preset's layer, so the catalog
1545
2038
  // must be read through the agent's scope chain (serviceForAgent falls
@@ -1563,20 +2056,41 @@ export function createChannel(ctx, initialAgent, options) {
1563
2056
  state.emit();
1564
2057
  };
1565
2058
  /**
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).
2059
+ * Rebuild the merged slash-command list: built-in locals, then registry
2060
+ * commands (plan/goal/…), then user-invocable skills from the DSH skill
2061
+ * registry (issue #86 — filesystem-discovered skills must appear in the
2062
+ * `/` menu and Tab completion, like /audit and /review). Skill entries
2063
+ * are completion-only: dispatch falls through to the model as plain text,
2064
+ * where dsh-tool-skill's pre-step hook injects the skill body — the same
2065
+ * path a hand-typed `/skill-name` takes. Registry and skill reads are
2066
+ * scoped to the LIVE agent, so this runs on `commands/change` +
2067
+ * `skills/change` and again whenever the live agent is swapped
2068
+ * (rewind/resume/new/model). A failed skill read restores the last
2069
+ * successfully merged skill set for the same agent (last-good), so a
2070
+ * transient provider failure never makes known skills vanish.
2071
+ */
2072
+ let commandListSeq = 0;
2073
+ /**
2074
+ * The last successfully merged skill entries, tagged with the agent whose
2075
+ * scope produced them. A failed catalog read restores these instead of
2076
+ * dropping skill entries from the menu until the next successful refresh
2077
+ * (last-good); the agent tag refuses cross-agent restores — a different
2078
+ * scope's skills may not exist for the live agent at all.
1570
2079
  */
2080
+ let lastGoodSkills;
1571
2081
  const refreshCommandList = () => {
2082
+ const target = agent;
2083
+ const token = ++commandListSeq;
1572
2084
  const merged = [...LOCAL_COMMANDS];
1573
2085
  if (commandService) {
1574
- for (const descriptor of commandService.list(agent)) {
2086
+ for (const descriptor of commandService.list(target)) {
1575
2087
  if (merged.some(command => command.name === descriptor.name))
1576
2088
  continue;
2089
+ const descriptions = commandTrees?.descriptions(descriptor.name);
1577
2090
  merged.push({
1578
2091
  name: descriptor.name,
1579
2092
  description: descriptor.description,
2093
+ ...(descriptions === undefined ? {} : { descriptions }),
1580
2094
  tag: descriptor.input?.hint,
1581
2095
  external: true,
1582
2096
  });
@@ -1584,8 +2098,73 @@ export function createChannel(ctx, initialAgent, options) {
1584
2098
  }
1585
2099
  state.commandList = merged;
1586
2100
  state.emit();
2101
+ // The skill catalog resolves asynchronously (filesystem providers scan
2102
+ // their roots), so skills append in a continuation; a newer refresh or
2103
+ // an agent swap supersedes this run (token/identity check, same rule as
2104
+ // refreshLoadedContext). Locals and registry commands win name
2105
+ // collisions — a skill named `plan` must not shadow the registry's.
2106
+ const skillsService = serviceForAgent(ctx, target, 'skills');
2107
+ if (skillsService === undefined)
2108
+ return;
2109
+ /** Last-good restore shared by the failed-read and incomplete-read
2110
+ * paths; the caller holds the staleness check. */
2111
+ const restoreLastGood = () => {
2112
+ const fallback = lastGoodSkills?.agent === target ? lastGoodSkills.commands : [];
2113
+ const restored = fallback.filter(entry => !merged.some(command => command.name === entry.name));
2114
+ if (restored.length === 0)
2115
+ return;
2116
+ state.commandList = [...merged, ...restored];
2117
+ state.emit();
2118
+ };
2119
+ // snapshot() over list(): only a COMPLETE observation is authoritative
2120
+ // — list() discards `complete`, so a provider failure or a rescan still
2121
+ // in flight would resolve as a partial/empty catalog and wrongly clear
2122
+ // the last-good set (dsh-skill's own consumer contract).
2123
+ void skillsService.snapshot({
2124
+ scope: target,
2125
+ cwd: target.session.header?.cwd ?? state.cwd,
2126
+ }).then((observation) => {
2127
+ if (token !== commandListSeq || target !== agent)
2128
+ return;
2129
+ if (!observation.complete) {
2130
+ // Incomplete (provider failure/rescan mid-flight): NOT authoritative
2131
+ // — never clear last-good or repopulate from the partial catalog.
2132
+ // The provider's next invalidate fires skills/change for the retry.
2133
+ ctx.logger.warn('skill command merge: incomplete catalog observation, keeping last-good skills');
2134
+ restoreLastGood();
2135
+ return;
2136
+ }
2137
+ const withSkills = [...merged];
2138
+ for (const skill of observation.skills) {
2139
+ if (!isUserInvocable(skill))
2140
+ continue;
2141
+ if (withSkills.some(command => command.name === skill.name))
2142
+ continue;
2143
+ withSkills.push({ name: skill.name, description: skill.description, skill: true });
2144
+ }
2145
+ const added = withSkills.slice(merged.length);
2146
+ lastGoodSkills = { agent: target, commands: added };
2147
+ // The sync phase already assigned `merged`; a complete read that adds
2148
+ // nothing leaves the state as-is (and authoritatively clears the
2149
+ // last-good set above).
2150
+ if (added.length === 0)
2151
+ return;
2152
+ state.commandList = withSkills;
2153
+ state.emit();
2154
+ }).catch((error) => {
2155
+ // A superseded read (a newer refresh or an agent swap beat it) says
2156
+ // nothing about the live menu: stay silent instead of logging a
2157
+ // misleading failure warning.
2158
+ if (token !== commandListSeq || target !== agent)
2159
+ return;
2160
+ ctx.logger.warn('skill command merge failed: %o', error);
2161
+ // Last-good: a transient provider failure (rescan error, permission
2162
+ // hiccup) must not make known skills vanish from completion.
2163
+ restoreLastGood();
2164
+ });
1587
2165
  };
1588
2166
  ctx.on('commands/change', refreshCommandList);
2167
+ ctx.on('skills/change', refreshCommandList);
1589
2168
  refreshCommandList();
1590
2169
  void refreshLoadedContext();
1591
2170
  let nextRowId = 0;
@@ -1594,20 +2173,27 @@ export function createChannel(ctx, initialAgent, options) {
1594
2173
  * service registers under `ctx.shell` (ShellExecutor; dsh-bash-local and
1595
2174
  * dsh-pwsh-local are the providers). */
1596
2175
  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). */
2176
+ /** Claude Code's `!` mode: execute in the current workspace provider and
2177
+ * render local-only transcript rows (never sent to the model). */
1599
2178
  const runLocalCommand = async (command, includeInContext) => {
1600
- state.rows.push({ id: nextRowId++, kind: 'local', text: command });
2179
+ const workspace = workspaceService.describe(state.cwd);
2180
+ state.rows.push({
2181
+ id: nextRowId++,
2182
+ kind: 'local',
2183
+ text: command,
2184
+ executionTarget: workspace.kind === 'local' ? workspace.badge : `${workspace.badge} · ${workspace.label}`,
2185
+ });
1601
2186
  state.emit();
1602
2187
  let output = '(no output)';
1603
- if (bash) {
2188
+ const executionShell = await workspaceService.commandShell(state.cwd) ?? bash;
2189
+ if (executionShell) {
1604
2190
  try {
1605
- const spec = bash.resolve({
2191
+ const spec = executionShell.resolve({
1606
2192
  command,
1607
2193
  workdir: state.cwd,
1608
2194
  timeoutMs: 30000,
1609
2195
  });
1610
- const result = await bash.run(spec);
2196
+ const result = await executionShell.run(spec);
1611
2197
  output =
1612
2198
  result.stdout.text.trim() ||
1613
2199
  result.stderr.text.trim() ||
@@ -2009,7 +2595,7 @@ ${output}
2009
2595
  const failure = event.data.error;
2010
2596
  if (failure !== undefined) {
2011
2597
  card.tool.status = 'error';
2012
- const errorText = `${failure.name}: ${failure.code}`;
2598
+ const errorText = toolErrorText(event);
2013
2599
  card.tool.errorText = errorText;
2014
2600
  state.contextSegments.tools += estimateTokens(errorText);
2015
2601
  }
@@ -2094,7 +2680,7 @@ ${output}
2094
2680
  state.rows.push({
2095
2681
  id: nextRowId,
2096
2682
  kind: 'interrupt',
2097
- text: 'Interrupted · What should Claude do instead?',
2683
+ text: t('interrupted-by-user') + t('interrupted-ask-next'),
2098
2684
  });
2099
2685
  nextRowId += 1;
2100
2686
  break;
@@ -2102,7 +2688,7 @@ ${output}
2102
2688
  const detail = reason.kind === 'error' ? reason.error.message : '';
2103
2689
  state.rows.push({ id: nextRowId, kind: 'notice', text: `turn ${reason.kind}${detail ? ` · ${detail}` : ''}` });
2104
2690
  nextRowId += 1;
2105
- state.notify(`Turn ${reason.kind}${detail ? ` · ${detail}` : ''}`, { color: 'error', timeoutMs: 8000 });
2691
+ state.notify(t('turn-failed', { detail: detail ? ` · ${detail}` : '' }), { color: 'error', timeoutMs: 8000 });
2106
2692
  break;
2107
2693
  }
2108
2694
  case 'request/context':
@@ -2137,7 +2723,7 @@ ${output}
2137
2723
  // Logged preset switch (blank sessions only, issue #8): a transcript
2138
2724
  // marker so a replayed log shows which composition produced the
2139
2725
  // turns after it. Not in dsh-session's typed union — matched here by
2140
- // name, the way `activity/status` is consumed in bindAgent.
2726
+ // name, like the other plugin-defined events above.
2141
2727
  if (event.type === 'agent-preset/selected') {
2142
2728
  const data = event.data;
2143
2729
  state.rows.push({
@@ -2159,29 +2745,97 @@ ${output}
2159
2745
  state.working = false;
2160
2746
  state.status = agent.status;
2161
2747
  state.emit();
2162
- // Live subscription list, rebound to a fresh agent by rewindTo.
2748
+ // Live subscription list and activity timer, rebound to every replacement
2749
+ // agent so no status from the previous session can leak across a swap.
2163
2750
  let agentSubscriptions = [];
2751
+ let activityTracker = new ActivityTracker({
2752
+ phrases: true,
2753
+ detailLimit: 40,
2754
+ showIdle: false,
2755
+ });
2756
+ let activityTickTimer;
2757
+ const stopActivityTick = () => {
2758
+ if (activityTickTimer === undefined)
2759
+ return;
2760
+ clearInterval(activityTickTimer);
2761
+ activityTickTimer = undefined;
2762
+ };
2763
+ /** Render the current tracker into the TUI-only projection. */
2764
+ const renderWorkingActivity = () => {
2765
+ if (options.activity === false) {
2766
+ state.workingActivity = undefined;
2767
+ return undefined;
2768
+ }
2769
+ const rendered = activityTracker.render();
2770
+ state.workingActivity = rendered;
2771
+ return rendered;
2772
+ };
2164
2773
  const bindAgent = () => {
2165
2774
  for (const dispose of agentSubscriptions)
2166
2775
  dispose();
2776
+ stopActivityTick();
2777
+ activityTracker = new ActivityTracker({
2778
+ phrases: true,
2779
+ detailLimit: 40,
2780
+ showIdle: false,
2781
+ });
2782
+ activityTracker.onAgentStatus(agent.status);
2783
+ renderWorkingActivity();
2784
+ activityTickTimer = setInterval(() => {
2785
+ const previous = state.workingActivity;
2786
+ const rendered = renderWorkingActivity();
2787
+ if (rendered === undefined)
2788
+ return;
2789
+ // Live phases deliberately wake at 500 ms even when the formatted line
2790
+ // has not crossed its next whole-second boundary: turnElapsedMs remains
2791
+ // a current state value, while line changes cover phrase rotation and
2792
+ // the short-lived completed-tool summary.
2793
+ if (rendered.phase === 'waiting' ||
2794
+ rendered.phase === 'thinking' ||
2795
+ rendered.phase === 'tool' ||
2796
+ previous?.phase !== rendered.phase ||
2797
+ previous.line !== rendered.line) {
2798
+ state.emit();
2799
+ }
2800
+ }, 500);
2801
+ activityTickTimer.unref();
2167
2802
  // Re-couple the channel-owned model selection to the new agent's
2168
2803
  // assembly/request waterfalls, then re-apply the persisted effort when
2169
2804
  // this agent's route offers it (dsh-agent installModelSelection).
2170
2805
  selection.current = undefined;
2171
2806
  selection.assembled = undefined;
2807
+ // {{model}} backfill (issue #155): a resumed agent's route lives only in
2808
+ // its session's request/header records — agentOptions.model stays
2809
+ // undefined unless cordis.yml pins a COMPLETE provider+model pair — so
2810
+ // the assemble-time persona variable `{{model}}` was registered but
2811
+ // valueless, and dsh-system-prompt's interpolate() throws before any
2812
+ // model call. Seed the selection from the channel's display route (on
2813
+ // resume it already carries the session's recorded route; on create it
2814
+ // matches the route the agent was created with). Per
2815
+ // installModelSelection's contract an absent effort restores the
2816
+ // provider/default behavior, so seeding never pins an effort the route
2817
+ // did not ask for; applyPreferredEffort below still upgrades the seed
2818
+ // when the user has a persisted preference the route offers.
2819
+ if (agent.options?.model === undefined && state.provider !== '' && state.model !== '') {
2820
+ selection.current = { provider: state.provider, model: state.model };
2821
+ }
2172
2822
  void applyPreferredEffort();
2823
+ refreshMode();
2173
2824
  agentSubscriptions = [
2174
2825
  installModelSelection(agent.ctx, selection),
2175
2826
  ctx.on('agent/status', ({ agent: subject, status }) => {
2176
2827
  if (subject !== agent)
2177
2828
  return;
2178
2829
  state.status = status;
2830
+ activityTracker.onAgentStatus(status);
2831
+ renderWorkingActivity();
2179
2832
  state.emit();
2180
2833
  }),
2181
2834
  ctx.on('agent/disposed', ({ agent: subject }) => {
2182
2835
  if (subject !== agent)
2183
2836
  return;
2184
2837
  state.status = 'disposed';
2838
+ stopActivityTick();
2185
2839
  state.emit();
2186
2840
  }),
2187
2841
  // Pending delivery is driven by the agent inbox: a claimed message
@@ -2215,24 +2869,13 @@ ${output}
2215
2869
  ctx.on('session/event', (session, event) => {
2216
2870
  if (session !== agent.session)
2217
2871
  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;
2872
+ activityTracker.onSessionEvent(event);
2873
+ renderWorkingActivity();
2874
+ // Mode-affecting atoms fold into the Shift+Tab mode indicator the
2875
+ // moment they land (whether appended by cycleMode or by hand).
2876
+ const eventType = event.type;
2877
+ if (eventType === 'plan/mode' || eventType === 'sandbox/mode' || eventType === 'approval/policy') {
2878
+ refreshMode();
2236
2879
  }
2237
2880
  renderEvent(event);
2238
2881
  // Streaming deltas (one event per token) take the frame-aligned
@@ -2245,15 +2888,30 @@ ${output}
2245
2888
  ];
2246
2889
  };
2247
2890
  bindAgent();
2891
+ // Cordis owns the Channel lifetime. Rebinding handles the common case;
2892
+ // this effect closes the final timer when the Channel's context unloads.
2893
+ const effect = ctx.effect;
2894
+ effect?.call(ctx, () => () => { stopActivityTick(); }, 'dsh-tui activity timer');
2248
2895
  // Statusline breadcrumb: current git branch of the session cwd (best-effort).
2249
- if (bash) {
2896
+ // Re-run when an agent swap adopts a different persisted cwd (/resume,
2897
+ // issue #96) so the breadcrumb never shows the previous workspace's branch.
2898
+ const refreshGitBranch = () => {
2899
+ state.gitBranch = undefined;
2900
+ if (!bash)
2901
+ return;
2902
+ // Capture the requested cwd: a /resume landing while this query is in
2903
+ // flight refreshes the branch for the NEW cwd, so a late reply from the
2904
+ // old workspace must be dropped (statusline staleness, issue #96 review).
2905
+ const requestedCwd = state.cwd;
2250
2906
  void bash
2251
2907
  .run(bash.resolve({
2252
2908
  command: 'git branch --show-current',
2253
- workdir: options.cwd,
2909
+ workdir: requestedCwd,
2254
2910
  timeoutMs: 3000,
2255
2911
  }))
2256
2912
  .then((result) => {
2913
+ if (state.cwd !== requestedCwd)
2914
+ return;
2257
2915
  const branch = result.stdout.text.trim();
2258
2916
  if (branch !== '') {
2259
2917
  state.gitBranch = branch;
@@ -2265,7 +2923,8 @@ ${output}
2265
2923
  // backend may be unavailable (no confinement yet) or the cwd may
2266
2924
  // not be a git repo. Either way the statusline simply stays blank.
2267
2925
  });
2268
- }
2926
+ };
2927
+ refreshGitBranch();
2269
2928
  return state;
2270
2929
  }
2271
2930
  /** Path basename for the resume-list title (`C:/a/b` → `b`). */
@@ -2273,6 +2932,56 @@ function basename(path) {
2273
2932
  const parts = path.split(/[\\/]/);
2274
2933
  return parts[parts.length - 1] ?? path;
2275
2934
  }
2935
+ /** Normalize a cwd for comparison: forward slashes, no trailing slash; case
2936
+ * folded when the platform's filesystem semantics are case-insensitive. */
2937
+ function normalizeCwd(path, caseInsensitive) {
2938
+ const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '');
2939
+ return caseInsensitive ? normalized.toLowerCase() : normalized;
2940
+ }
2941
+ /**
2942
+ * `/resume` project filter (issue #96): exact cwd match, PLUS sessions
2943
+ * recorded in a subdirectory — pre-upgrade launches recorded the launch
2944
+ * subdirectory as the header cwd, and with the cwd default now resolving to
2945
+ * the git worktree root an exact match would hide those sessions forever.
2946
+ * They belong to the same workspace, so they stay listed. Comparison follows
2947
+ * the platform's filesystem semantics (case-insensitive on Windows — a
2948
+ * pre-upgrade header may record `C:\Repo` where the current launch resolves
2949
+ * `c:\repo`). `caseInsensitive` is a parameter (not a platform read) so the
2950
+ * verifier can exercise both modes on any host. Exported for
2951
+ * scripts/verify-session-cwd.mjs.
2952
+ *
2953
+ * Boundary rule (issue #153): container directories are nobody's workspace.
2954
+ * $HOME and the Windows root forms — plain drive roots (`C:`), UNC share
2955
+ * roots (`//server/share`), and extended-length roots (`//?/C:`,
2956
+ * `//?/UNC/server/share`) — are ancestors of unrelated projects, so the
2957
+ * descendant rules below would list every session on the machine from `~`
2958
+ * (and every session on the drive/share from those roots). At these
2959
+ * boundaries, in either direction, only an exact match passes.
2960
+ */
2961
+ export function sessionCwdMatches(stateCwd, headerCwd, caseInsensitive = process.platform === 'win32') {
2962
+ const cwd = normalizeCwd(stateCwd, caseInsensitive);
2963
+ const recorded = normalizeCwd(headerCwd, caseInsensitive);
2964
+ if (recorded === '' || cwd === '')
2965
+ return false;
2966
+ const home = normalizeCwd(homeDir(), caseInsensitive);
2967
+ // Paths below arrive backslash-normalized (`\\server\share` →
2968
+ // `//server/share`, `\\?\C:\` → `//?/C:`), trailing slashes stripped.
2969
+ const isContainer = (path) => (home !== '' && path === home) ||
2970
+ /^[a-z]:$/i.test(path) || // drive root: C:
2971
+ /^\/\/[^/]+\/[^/]+$/.test(path) || // UNC share root: //server/share
2972
+ /^\/\/\?\/[a-z]:$/i.test(path) || // extended drive root: //?/C:
2973
+ /^\/\/\?\/unc\/[^/]+\/[^/]+$/i.test(path); // extended UNC root: //?/UNC/server/share
2974
+ if (isContainer(cwd) || isContainer(recorded))
2975
+ return recorded === cwd;
2976
+ return (recorded === cwd ||
2977
+ // Pre-upgrade subdirectory session of this workspace.
2978
+ recorded.startsWith(`${cwd}/`) ||
2979
+ // Resumed INTO a pre-upgrade subdirectory session (state.cwd adopted its
2980
+ // recorded subdirectory): the workspace-root sessions it belongs with
2981
+ // must stay visible, or /resume looks like it lost them for the rest of
2982
+ // the process lifetime (review leftover).
2983
+ cwd.startsWith(`${recorded}/`));
2984
+ }
2276
2985
  /** Context-bar token estimate (pi-nano-context: ~4 chars per token). */
2277
2986
  function estimateTokens(text) {
2278
2987
  return Math.ceil(text.length / 4);
@@ -2355,84 +3064,146 @@ const MENTION_MAX_DIR_ENTRIES = 200;
2355
3064
  function mentionFs(ctx) {
2356
3065
  return ctx.get('fs');
2357
3066
  }
3067
+ function mentionAttachments(ctx) {
3068
+ return ctx.get('attachments');
3069
+ }
3070
+ const MENTION_IMAGE_MEDIA_TYPES = {
3071
+ '.png': 'image/png',
3072
+ '.jpg': 'image/jpeg',
3073
+ '.jpeg': 'image/jpeg',
3074
+ '.webp': 'image/webp',
3075
+ '.gif': 'image/gif',
3076
+ };
3077
+ function mentionImageMediaType(path) {
3078
+ return MENTION_IMAGE_MEDIA_TYPES[extname(path).toLowerCase()];
3079
+ }
2358
3080
  /**
2359
3081
  * 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.
3082
+ * attachment blocks: supported image files become durable image blocks,
3083
+ * other files contribute capped text, and directories contribute a shallow
3084
+ * listing. Reads always go through the active fs service, so provider-owned
3085
+ * workspaces keep their routing semantics. The typed text stays first and
3086
+ * verbatim. Best-effort failures degrade to `missing`, never a failed send.
2365
3087
  */
2366
- export async function expandMentions(fs, cwd, text) {
3088
+ export async function expandMentions(fs, cwd, text, attachments, stagedImages) {
2367
3089
  const blocks = [{ type: 'text', text }];
2368
3090
  const attached = [];
2369
3091
  const missing = [];
2370
3092
  const mentions = extractMentions(text);
2371
- if (!fs || mentions.length === 0)
2372
- return { blocks, attached, missing };
2373
3093
  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') {
3094
+ let imageCount = 0;
3095
+ let imageBytes = 0;
3096
+ if (fs !== undefined) {
3097
+ for (const mention of mentions) {
3098
+ const imageMediaType = mentionImageMediaType(mention.path);
3099
+ if (budget <= 0 && imageMediaType === undefined)
3100
+ break;
3101
+ // Mentions resolve against the session cwd, same as the model-facing fs
3102
+ // tools; absolute paths pass through untouched.
3103
+ const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
3104
+ let target;
3105
+ let info;
2391
3106
  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);
3107
+ target = await fs.resolve(absolute);
3108
+ info = await fs.stat(target);
2405
3109
  }
2406
3110
  catch {
2407
- // Binary/undecodable or unreadable — report it like a miss.
2408
3111
  missing.push(mention.path);
3112
+ continue;
2409
3113
  }
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)`);
3114
+ if (info?.type === 'file') {
3115
+ if (imageMediaType !== undefined && attachments !== undefined && fs.readBytes !== undefined) {
3116
+ const limits = attachments.imageLimits;
3117
+ if (!limits.mediaTypes.includes(imageMediaType) || imageCount >= limits.maxImagesPerMessage) {
3118
+ missing.push(mention.path);
3119
+ continue;
3120
+ }
3121
+ try {
3122
+ const data = await fs.readBytes(target, undefined, limits.maxImageBytes);
3123
+ if (imageBytes + data.byteLength > limits.maxMessageImageBytes) {
3124
+ missing.push(mention.path);
3125
+ continue;
3126
+ }
3127
+ const attachment = await attachments.saveImage({
3128
+ data,
3129
+ mediaType: imageMediaType,
3130
+ name: basename(target.displayPath),
3131
+ });
3132
+ blocks.push({ type: 'image', attachment });
3133
+ imageCount += 1;
3134
+ imageBytes += data.byteLength;
3135
+ attached.push(mention.path);
3136
+ }
3137
+ catch {
3138
+ missing.push(mention.path);
3139
+ }
3140
+ continue;
2420
3141
  }
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);
3142
+ try {
3143
+ const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
3144
+ let content = await fs.readText(target);
3145
+ let truncated = false;
3146
+ if (content.length > cap) {
3147
+ content = content.slice(0, cap);
3148
+ truncated = true;
3149
+ }
3150
+ budget -= content.length;
3151
+ blocks.push({
3152
+ type: 'text',
3153
+ text: `<attached-file path="${mention.path}">\n${content}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
3154
+ });
3155
+ attached.push(mention.path);
3156
+ }
3157
+ catch {
3158
+ // Binary/undecodable or unreadable — report it like a miss.
3159
+ missing.push(mention.path);
3160
+ }
3161
+ continue;
2428
3162
  }
2429
- catch {
2430
- missing.push(mention.path);
3163
+ if (info?.type === 'directory') {
3164
+ try {
3165
+ const entries = await fs.listDir(target);
3166
+ const listing = entries
3167
+ .slice(0, MENTION_MAX_DIR_ENTRIES)
3168
+ .map(entry => (entry.type === 'directory' ? `${entry.name}/` : entry.name));
3169
+ if (entries.length > MENTION_MAX_DIR_ENTRIES) {
3170
+ listing.push(`… (${entries.length - MENTION_MAX_DIR_ENTRIES} more)`);
3171
+ }
3172
+ const body = listing.join('\n');
3173
+ budget -= body.length;
3174
+ blocks.push({
3175
+ type: 'text',
3176
+ text: `<attached-directory path="${mention.path}">\n${body}\n</attached-directory>`,
3177
+ });
3178
+ attached.push(mention.path);
3179
+ }
3180
+ catch {
3181
+ missing.push(mention.path);
3182
+ }
3183
+ continue;
2431
3184
  }
2432
- continue;
3185
+ // Absent (stat → undefined) or a special file.
3186
+ missing.push(mention.path);
3187
+ }
3188
+ }
3189
+ if (attachments !== undefined && stagedImages !== undefined) {
3190
+ const limits = attachments.imageLimits;
3191
+ for (const [token, attachment] of stagedImages) {
3192
+ if (!text.includes(token))
3193
+ continue;
3194
+ // A referenced-but-dropped staged image must be loud: silently sending
3195
+ // the bare token would leave the user believing the image reached the
3196
+ // model. Reuse the missing-mention warning channel.
3197
+ if (imageCount >= limits.maxImagesPerMessage
3198
+ || imageBytes + attachment.bytes > limits.maxMessageImageBytes
3199
+ || !limits.mediaTypes.includes(attachment.mediaType)) {
3200
+ missing.push(token);
3201
+ continue;
3202
+ }
3203
+ blocks.push({ type: 'image', attachment });
3204
+ imageCount += 1;
3205
+ imageBytes += attachment.bytes;
2433
3206
  }
2434
- // Absent (stat → undefined) or a special file.
2435
- missing.push(mention.path);
2436
3207
  }
2437
3208
  return { blocks, attached, missing };
2438
3209
  }