@deepseek-harness-tui/dsh-tui 0.8.8 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/README.md +60 -238
  2. package/bin/dsh-tui.js +172 -5
  3. package/cordis.patch.yml +16 -4
  4. package/dsh-ecosystem-spec/protocols/profile-definitions.d.ts +13 -13
  5. package/dsh-ecosystem-spec/protocols/profile-definitions.js +59 -59
  6. package/dsh-ecosystem-spec/protocols/tui-channel-http.d.ts +15 -15
  7. package/dsh-ecosystem-spec/protocols/tui-channel-http.js +32 -32
  8. package/dsh-ecosystem-spec/protocols/tui-channel.d.ts +59 -59
  9. package/dsh-ecosystem-spec/protocols/tui-channel.js +166 -166
  10. package/dsh-ecosystem-spec/protocols/tui-contributions.d.ts +42 -42
  11. package/dsh-ecosystem-spec/protocols/tui-contributions.js +143 -143
  12. package/dsh-ecosystem-spec/registry/README.md +14 -14
  13. package/dsh-ecosystem-spec/registry/host-descriptor.tui.example.json +56 -56
  14. package/dsh-ecosystem-spec/registry/permissions-0.1.json +13 -13
  15. package/dsh-ecosystem-spec/registry/registry-0.15.json +149 -149
  16. package/lib/types/cc/hyperlink.d.ts +7 -1
  17. package/lib/types/cc/hyperlink.d.ts.map +1 -1
  18. package/lib/types/cc/hyperlink.js +3 -2
  19. package/lib/types/cc/markdown.d.ts.map +1 -1
  20. package/lib/types/cc/markdown.js +33 -3
  21. package/lib/types/cc/sessionColors.d.ts +27 -0
  22. package/lib/types/cc/sessionColors.d.ts.map +1 -0
  23. package/lib/types/cc/sessionColors.js +39 -0
  24. package/lib/types/commands.d.ts.map +1 -1
  25. package/lib/types/commands.js +10 -2
  26. package/lib/types/components/ActivityPicker.d.ts +4 -1
  27. package/lib/types/components/ActivityPicker.d.ts.map +1 -1
  28. package/lib/types/components/ActivityPicker.js +2 -2
  29. package/lib/types/components/AutoRecapRow.d.ts +23 -0
  30. package/lib/types/components/AutoRecapRow.d.ts.map +1 -0
  31. package/lib/types/components/AutoRecapRow.js +23 -0
  32. package/lib/types/components/BtwPanel.d.ts.map +1 -1
  33. package/lib/types/components/BtwPanel.js +1 -1
  34. package/lib/types/components/Chat/SubagentMessage.d.ts +2 -1
  35. package/lib/types/components/Chat/SubagentMessage.d.ts.map +1 -1
  36. package/lib/types/components/Chat/SubagentMessage.js +11 -3
  37. package/lib/types/components/ColorPicker.d.ts +17 -0
  38. package/lib/types/components/ColorPicker.d.ts.map +1 -0
  39. package/lib/types/components/ColorPicker.js +20 -0
  40. package/lib/types/components/CommandSuggestions.d.ts +23 -4
  41. package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
  42. package/lib/types/components/CommandSuggestions.js +41 -14
  43. package/lib/types/components/ContextBarView.d.ts +33 -0
  44. package/lib/types/components/ContextBarView.d.ts.map +1 -0
  45. package/lib/types/components/ContextBarView.js +46 -0
  46. package/lib/types/components/EffortInputBorder.d.ts +12 -1
  47. package/lib/types/components/EffortInputBorder.d.ts.map +1 -1
  48. package/lib/types/components/EffortInputBorder.js +22 -5
  49. package/lib/types/components/EffortSlider.d.ts +4 -1
  50. package/lib/types/components/EffortSlider.d.ts.map +1 -1
  51. package/lib/types/components/EffortSlider.js +4 -2
  52. package/lib/types/components/ExtensionDialog.js +6 -2
  53. package/lib/types/components/FileActionsPanel.d.ts +22 -0
  54. package/lib/types/components/FileActionsPanel.d.ts.map +1 -0
  55. package/lib/types/components/FileActionsPanel.js +28 -0
  56. package/lib/types/components/FileSuggestions.d.ts +26 -7
  57. package/lib/types/components/FileSuggestions.d.ts.map +1 -1
  58. package/lib/types/components/FileSuggestions.js +39 -12
  59. package/lib/types/components/GoalTodoPanel.d.ts +26 -2
  60. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  61. package/lib/types/components/GoalTodoPanel.js +86 -17
  62. package/lib/types/components/HelpMenu.d.ts +7 -1
  63. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  64. package/lib/types/components/HelpMenu.js +12 -4
  65. package/lib/types/components/HistorySearchDialog.d.ts +4 -1
  66. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  67. package/lib/types/components/HistorySearchDialog.js +2 -2
  68. package/lib/types/components/LangPicker.d.ts +14 -0
  69. package/lib/types/components/LangPicker.d.ts.map +1 -0
  70. package/lib/types/components/LangPicker.js +17 -0
  71. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  72. package/lib/types/components/LoadedContextPanel.js +3 -1
  73. package/lib/types/components/Markdown.d.ts.map +1 -1
  74. package/lib/types/components/Markdown.js +12 -3
  75. package/lib/types/components/MessageList.d.ts +45 -2
  76. package/lib/types/components/MessageList.d.ts.map +1 -1
  77. package/lib/types/components/MessageList.js +560 -41
  78. package/lib/types/components/ModelPicker.d.ts +27 -2
  79. package/lib/types/components/ModelPicker.d.ts.map +1 -1
  80. package/lib/types/components/ModelPicker.js +26 -6
  81. package/lib/types/components/PermissionsPicker.d.ts +21 -0
  82. package/lib/types/components/PermissionsPicker.d.ts.map +1 -0
  83. package/lib/types/components/PermissionsPicker.js +27 -0
  84. package/lib/types/components/PlanPicker.d.ts +15 -0
  85. package/lib/types/components/PlanPicker.d.ts.map +1 -0
  86. package/lib/types/components/PlanPicker.js +18 -0
  87. package/lib/types/components/PresetPicker.d.ts +4 -1
  88. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  89. package/lib/types/components/PresetPicker.js +2 -2
  90. package/lib/types/components/PromptInput.d.ts +4 -3
  91. package/lib/types/components/PromptInput.d.ts.map +1 -1
  92. package/lib/types/components/PromptInput.js +602 -75
  93. package/lib/types/components/RecapPanel.d.ts +25 -0
  94. package/lib/types/components/RecapPanel.d.ts.map +1 -0
  95. package/lib/types/components/RecapPanel.js +55 -0
  96. package/lib/types/components/RewindPicker.d.ts +12 -1
  97. package/lib/types/components/RewindPicker.d.ts.map +1 -1
  98. package/lib/types/components/RewindPicker.js +6 -4
  99. package/lib/types/components/ScrollbarGutter.d.ts +28 -0
  100. package/lib/types/components/ScrollbarGutter.d.ts.map +1 -0
  101. package/lib/types/components/ScrollbarGutter.js +119 -0
  102. package/lib/types/components/Select.d.ts +8 -1
  103. package/lib/types/components/Select.d.ts.map +1 -1
  104. package/lib/types/components/Select.js +4 -2
  105. package/lib/types/components/SkillsPicker.d.ts +4 -1
  106. package/lib/types/components/SkillsPicker.d.ts.map +1 -1
  107. package/lib/types/components/SkillsPicker.js +2 -2
  108. package/lib/types/components/SubagentCard.d.ts +2 -1
  109. package/lib/types/components/SubagentCard.d.ts.map +1 -1
  110. package/lib/types/components/SubagentCard.js +9 -2
  111. package/lib/types/components/SubagentDashboard.d.ts +4 -0
  112. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  113. package/lib/types/components/SubagentDashboard.js +9 -1
  114. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
  115. package/lib/types/components/SubagentDetailScene.js +5 -4
  116. package/lib/types/components/SuggestionCard.d.ts +65 -0
  117. package/lib/types/components/SuggestionCard.d.ts.map +1 -0
  118. package/lib/types/components/SuggestionCard.js +77 -0
  119. package/lib/types/components/ThemePicker.d.ts +4 -1
  120. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  121. package/lib/types/components/ThemePicker.js +2 -2
  122. package/lib/types/components/ThinkingToggle.d.ts +4 -1
  123. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  124. package/lib/types/components/ThinkingToggle.js +2 -2
  125. package/lib/types/components/TimelineRail.d.ts +53 -0
  126. package/lib/types/components/TimelineRail.d.ts.map +1 -0
  127. package/lib/types/components/TimelineRail.js +197 -0
  128. package/lib/types/components/WorkspaceFlowPicker.d.ts +6 -1
  129. package/lib/types/components/WorkspaceFlowPicker.d.ts.map +1 -1
  130. package/lib/types/components/WorkspaceFlowPicker.js +4 -2
  131. package/lib/types/components/WorkspaceMenuPicker.d.ts +19 -0
  132. package/lib/types/components/WorkspaceMenuPicker.d.ts.map +1 -0
  133. package/lib/types/components/WorkspaceMenuPicker.js +19 -0
  134. package/lib/types/components/WorkspacePicker.d.ts +4 -1
  135. package/lib/types/components/WorkspacePicker.d.ts.map +1 -1
  136. package/lib/types/components/WorkspacePicker.js +2 -2
  137. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  138. package/lib/types/components/approvals/ApprovalPanel.js +5 -1
  139. package/lib/types/components/design-system/ListItem.d.ts +8 -1
  140. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  141. package/lib/types/components/design-system/ListItem.js +8 -3
  142. package/lib/types/components/design-system/ThemeProvider.d.ts +7 -0
  143. package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
  144. package/lib/types/components/design-system/ThemeProvider.js +2 -1
  145. package/lib/types/components/messages/AssistantTextMessage.d.ts +5 -2
  146. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -1
  147. package/lib/types/components/messages/AssistantTextMessage.js +6 -2
  148. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -1
  149. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  150. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -4
  151. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +15 -1
  152. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  153. package/lib/types/components/messages/AssistantToolUseMessage.js +37 -7
  154. package/lib/types/components/messages/UserPromptMessage.d.ts +2 -1
  155. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  156. package/lib/types/components/questions/AskUserQuestionPanel.d.ts +7 -3
  157. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  158. package/lib/types/components/questions/AskUserQuestionPanel.js +75 -11
  159. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  160. package/lib/types/components/questions/PlanReviewPanel.js +13 -2
  161. package/lib/types/components/sessions/SessionListRow.d.ts +4 -1
  162. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  163. package/lib/types/components/sessions/SessionListRow.js +4 -2
  164. package/lib/types/components/trajectory/HotspotView.d.ts +7 -1
  165. package/lib/types/components/trajectory/HotspotView.d.ts.map +1 -1
  166. package/lib/types/components/trajectory/HotspotView.js +8 -2
  167. package/lib/types/components/trajectory/Ledger.d.ts +9 -1
  168. package/lib/types/components/trajectory/Ledger.d.ts.map +1 -1
  169. package/lib/types/components/trajectory/Ledger.js +23 -5
  170. package/lib/types/components/trajectory/WaveBand.d.ts +10 -1
  171. package/lib/types/components/trajectory/WaveBand.d.ts.map +1 -1
  172. package/lib/types/components/trajectory/WaveBand.js +7 -2
  173. package/lib/types/dsh-adapter/channel.d.ts +117 -7
  174. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  175. package/lib/types/dsh-adapter/channel.js +1301 -122
  176. package/lib/types/dsh-adapter/compat/index.d.ts +8 -6
  177. package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
  178. package/lib/types/dsh-adapter/compat/index.js +8 -6
  179. package/lib/types/dsh-adapter/compat/sessionLog.d.ts +150 -0
  180. package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
  181. package/lib/types/dsh-adapter/compat/sessionLog.js +518 -41
  182. package/lib/types/dsh-adapter/contract.d.ts +1 -1
  183. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  184. package/lib/types/dsh-adapter/contract.js +1 -0
  185. package/lib/types/dsh-adapter/extension-events.d.ts +1 -1
  186. package/lib/types/dsh-adapter/extension-events.d.ts.map +1 -1
  187. package/lib/types/dsh-adapter/index.d.ts +20 -2
  188. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  189. package/lib/types/dsh-adapter/index.js +9 -1
  190. package/lib/types/dsh-adapter/plugin.d.ts +0 -9
  191. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  192. package/lib/types/dsh-adapter/plugin.js +453 -14
  193. package/lib/types/dsh-adapter/presets.d.ts +18 -0
  194. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  195. package/lib/types/dsh-adapter/presets.js +31 -0
  196. package/lib/types/dsh-adapter/providerWizard.d.ts +29 -1
  197. package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -1
  198. package/lib/types/dsh-adapter/providerWizard.js +89 -5
  199. package/lib/types/dsh-adapter/questions.d.ts +19 -2
  200. package/lib/types/dsh-adapter/questions.d.ts.map +1 -1
  201. package/lib/types/dsh-adapter/questions.js +53 -10
  202. package/lib/types/dsh-adapter/recap.d.ts +49 -0
  203. package/lib/types/dsh-adapter/recap.d.ts.map +1 -0
  204. package/lib/types/dsh-adapter/recap.js +121 -0
  205. package/lib/types/dsh-adapter/sessionTree.d.ts +314 -0
  206. package/lib/types/dsh-adapter/sessionTree.d.ts.map +1 -0
  207. package/lib/types/dsh-adapter/sessionTree.js +954 -0
  208. package/lib/types/dsh-adapter/shortcuts.d.ts +15 -44
  209. package/lib/types/dsh-adapter/shortcuts.d.ts.map +1 -1
  210. package/lib/types/dsh-adapter/shortcuts.js +28 -165
  211. package/lib/types/dsh-adapter/types.d.ts +1 -1
  212. package/lib/types/dsh-adapter/types.d.ts.map +1 -1
  213. package/lib/types/herdr.js +1 -1
  214. package/lib/types/i18n.d.ts +770 -181
  215. package/lib/types/i18n.d.ts.map +1 -1
  216. package/lib/types/i18n.js +257 -80
  217. package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -1
  218. package/lib/types/ink/components/AlternateScreen.js +13 -1
  219. package/lib/types/ink/components/App.d.ts +12 -1
  220. package/lib/types/ink/components/App.d.ts.map +1 -1
  221. package/lib/types/ink/components/App.js +101 -4
  222. package/lib/types/ink/components/ClockContext.d.ts.map +1 -1
  223. package/lib/types/ink/components/ClockContext.js +2 -0
  224. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  225. package/lib/types/ink/components/ScrollBox.js +170 -91
  226. package/lib/types/ink/constants.d.ts +9 -0
  227. package/lib/types/ink/constants.d.ts.map +1 -1
  228. package/lib/types/ink/constants.js +9 -0
  229. package/lib/types/ink/events/click-event.d.ts +7 -16
  230. package/lib/types/ink/events/click-event.d.ts.map +1 -1
  231. package/lib/types/ink/events/click-event.js +16 -19
  232. package/lib/types/ink/events/event-handlers.d.ts +11 -1
  233. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  234. package/lib/types/ink/events/event-handlers.js +2 -0
  235. package/lib/types/ink/events/input-event.d.ts +9 -0
  236. package/lib/types/ink/events/input-event.d.ts.map +1 -1
  237. package/lib/types/ink/events/input-event.js +4 -0
  238. package/lib/types/ink/events/pointer-event.d.ts +61 -0
  239. package/lib/types/ink/events/pointer-event.d.ts.map +1 -0
  240. package/lib/types/ink/events/pointer-event.js +61 -0
  241. package/lib/types/ink/events/wheel-event.d.ts +17 -0
  242. package/lib/types/ink/events/wheel-event.d.ts.map +1 -0
  243. package/lib/types/ink/events/wheel-event.js +20 -0
  244. package/lib/types/ink/geometry-trace.d.ts +51 -0
  245. package/lib/types/ink/geometry-trace.d.ts.map +1 -0
  246. package/lib/types/ink/geometry-trace.js +83 -0
  247. package/lib/types/ink/hit-test.d.ts +56 -1
  248. package/lib/types/ink/hit-test.d.ts.map +1 -1
  249. package/lib/types/ink/hit-test.js +146 -8
  250. package/lib/types/ink/ink.d.ts +58 -1
  251. package/lib/types/ink/ink.d.ts.map +1 -1
  252. package/lib/types/ink/ink.js +230 -29
  253. package/lib/types/ink/log-update.d.ts.map +1 -1
  254. package/lib/types/ink/log-update.js +21 -0
  255. package/lib/types/ink/node-cache.d.ts +5 -0
  256. package/lib/types/ink/node-cache.d.ts.map +1 -1
  257. package/lib/types/ink/parse-keypress.d.ts +8 -0
  258. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  259. package/lib/types/ink/parse-keypress.js +101 -12
  260. package/lib/types/ink/render-node-to-output.d.ts +10 -0
  261. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  262. package/lib/types/ink/render-node-to-output.js +111 -18
  263. package/lib/types/ink/timeline-rail.d.ts +138 -0
  264. package/lib/types/ink/timeline-rail.d.ts.map +1 -0
  265. package/lib/types/ink/timeline-rail.js +179 -0
  266. package/lib/types/ink/wrap-text.d.ts.map +1 -1
  267. package/lib/types/ink/wrap-text.js +47 -5
  268. package/lib/types/migrationPrefs.d.ts +60 -0
  269. package/lib/types/migrationPrefs.d.ts.map +1 -0
  270. package/lib/types/migrationPrefs.js +97 -0
  271. package/lib/types/modelGroups.d.ts +72 -0
  272. package/lib/types/modelGroups.d.ts.map +1 -0
  273. package/lib/types/modelGroups.js +97 -0
  274. package/lib/types/modelRecents.d.ts +39 -0
  275. package/lib/types/modelRecents.d.ts.map +1 -0
  276. package/lib/types/modelRecents.js +97 -0
  277. package/lib/types/native-ts/yoga-layout/index.d.ts +11 -0
  278. package/lib/types/native-ts/yoga-layout/index.d.ts.map +1 -1
  279. package/lib/types/native-ts/yoga-layout/index.js +31 -1
  280. package/lib/types/oauth.d.ts +15 -0
  281. package/lib/types/oauth.d.ts.map +1 -0
  282. package/lib/types/oauth.js +14 -0
  283. package/lib/types/reload.d.ts +109 -0
  284. package/lib/types/reload.d.ts.map +1 -0
  285. package/lib/types/reload.js +118 -0
  286. package/lib/types/screens/Chat.d.ts +3 -1
  287. package/lib/types/screens/Chat.d.ts.map +1 -1
  288. package/lib/types/screens/Chat.js +1377 -371
  289. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  290. package/lib/types/screens/SessionBrowser.js +56 -29
  291. package/lib/types/screens/SessionTree.d.ts +27 -0
  292. package/lib/types/screens/SessionTree.d.ts.map +1 -0
  293. package/lib/types/screens/SessionTree.js +555 -0
  294. package/lib/types/screens/Settings.d.ts.map +1 -1
  295. package/lib/types/screens/Settings.js +64 -22
  296. package/lib/types/screens/StatusLine.d.ts +0 -8
  297. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  298. package/lib/types/screens/StatusLine.js +224 -45
  299. package/lib/types/screens/StatusMetrics.d.ts +35 -3
  300. package/lib/types/screens/StatusMetrics.d.ts.map +1 -1
  301. package/lib/types/screens/StatusMetrics.js +63 -8
  302. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -1
  303. package/lib/types/screens/TrajectoryScene.js +81 -14
  304. package/lib/types/screens/chatOverlay.d.ts +237 -0
  305. package/lib/types/screens/chatOverlay.d.ts.map +1 -0
  306. package/lib/types/screens/chatOverlay.js +116 -0
  307. package/lib/types/tips.d.ts.map +1 -1
  308. package/lib/types/tips.js +64 -10
  309. package/lib/types/tuiDisplayPrefs.d.ts +15 -0
  310. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  311. package/lib/types/tuiDisplayPrefs.js +9 -0
  312. package/lib/types/update.d.ts +139 -0
  313. package/lib/types/update.d.ts.map +1 -1
  314. package/lib/types/update.js +382 -30
  315. package/lib/types/utils/debug.d.ts +10 -0
  316. package/lib/types/utils/debug.d.ts.map +1 -1
  317. package/lib/types/utils/debug.js +24 -0
  318. package/lib/types/utils/fileTarget.d.ts +46 -0
  319. package/lib/types/utils/fileTarget.d.ts.map +1 -0
  320. package/lib/types/utils/fileTarget.js +178 -0
  321. package/lib/types/utils/keymap.d.ts +134 -0
  322. package/lib/types/utils/keymap.d.ts.map +1 -0
  323. package/lib/types/utils/keymap.js +311 -0
  324. package/lib/types/utils/mentions.d.ts +25 -2
  325. package/lib/types/utils/mentions.d.ts.map +1 -1
  326. package/lib/types/utils/mentions.js +69 -10
  327. package/lib/types/utils/openExternal.d.ts +50 -0
  328. package/lib/types/utils/openExternal.d.ts.map +1 -0
  329. package/lib/types/utils/openExternal.js +170 -0
  330. package/lib/types/utils/paths.d.ts.map +1 -1
  331. package/lib/types/utils/paths.js +1 -0
  332. package/node_modules/@deepseek-harness-tui/dsh-auth/LICENSE +21 -0
  333. package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +140 -0
  334. package/node_modules/@deepseek-harness-tui/dsh-auth/cordis.patch.yml +11 -0
  335. package/node_modules/@deepseek-harness-tui/dsh-auth/dsh-plugin.json +44 -0
  336. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/command.d.ts +14 -0
  337. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/command.js +67 -0
  338. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.d.ts +64 -0
  339. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.js +170 -0
  340. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.d.ts +79 -0
  341. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.js +196 -0
  342. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.d.ts +68 -0
  343. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.js +193 -0
  344. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/opener.d.ts +32 -0
  345. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/opener.js +101 -0
  346. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.d.ts +31 -0
  347. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.js +68 -0
  348. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/service.d.ts +69 -0
  349. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/service.js +129 -0
  350. package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +70 -0
  351. package/node_modules/@dsh-std/command/CHANGELOG.md +8 -8
  352. package/node_modules/@dsh-std/command/LICENSE +21 -21
  353. package/node_modules/@dsh-std/command/README.md +26 -26
  354. package/node_modules/@dsh-std/command/README.zh.md +28 -28
  355. package/node_modules/@dsh-std/command/lib/index.js.map +1 -1
  356. package/node_modules/@dsh-std/command/package.json +22 -22
  357. package/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  358. package/node_modules/@dsh-std/connection/LICENSE +21 -21
  359. package/node_modules/@dsh-std/connection/README.md +42 -42
  360. package/node_modules/@dsh-std/connection/README.zh.md +42 -42
  361. package/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  362. package/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  363. package/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  364. package/node_modules/@dsh-std/connection/package.json +20 -20
  365. package/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  366. package/node_modules/@dsh-std/core/LICENSE +21 -21
  367. package/node_modules/@dsh-std/core/README.md +13 -13
  368. package/node_modules/@dsh-std/core/README.zh.md +13 -13
  369. package/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  370. package/node_modules/@dsh-std/core/package.json +21 -21
  371. package/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
  372. package/node_modules/@dsh-std/manifest/LICENSE +21 -21
  373. package/node_modules/@dsh-std/manifest/README.md +5 -5
  374. package/node_modules/@dsh-std/manifest/README.zh.md +5 -5
  375. package/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
  376. package/node_modules/@dsh-std/manifest/package.json +20 -20
  377. package/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
  378. package/node_modules/@dsh-std/messages/CHANGELOG.md +8 -8
  379. package/node_modules/@dsh-std/messages/LICENSE +21 -21
  380. package/node_modules/@dsh-std/messages/README.md +5 -5
  381. package/node_modules/@dsh-std/messages/README.zh.md +7 -7
  382. package/node_modules/@dsh-std/messages/lib/index.js.map +1 -1
  383. package/node_modules/@dsh-std/messages/package.json +20 -20
  384. package/node_modules/@dsh-std/messages/schema/message-observer-envelope-0.15.schema.json +54 -54
  385. package/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
  386. package/node_modules/@dsh-std/presentation/LICENSE +21 -21
  387. package/node_modules/@dsh-std/presentation/README.md +27 -27
  388. package/node_modules/@dsh-std/presentation/README.zh.md +29 -29
  389. package/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
  390. package/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
  391. package/node_modules/@dsh-std/presentation/package.json +25 -25
  392. package/node_modules/@dsh-std/storage/CHANGELOG.md +8 -8
  393. package/node_modules/@dsh-std/storage/LICENSE +21 -21
  394. package/node_modules/@dsh-std/storage/README.md +5 -5
  395. package/node_modules/@dsh-std/storage/README.zh.md +7 -7
  396. package/node_modules/@dsh-std/storage/lib/index.js.map +1 -1
  397. package/node_modules/@dsh-std/storage/package.json +20 -20
  398. package/node_modules/@dsh-std/storage/schema/local-storage-0.15.schema.json +56 -56
  399. package/package.json +19 -5
  400. package/presets/liangshen/.dsh-tui-managed.json +1 -1
  401. package/presets/liangshen/instruction-hint.mjs +8 -0
  402. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  403. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/LICENSE +21 -21
  404. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.md +42 -42
  405. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.zh.md +42 -42
  406. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  407. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  408. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  409. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/package.json +20 -20
  410. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  411. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/LICENSE +21 -21
  412. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.md +13 -13
  413. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.zh.md +13 -13
  414. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  415. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/package.json +21 -21
  416. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
  417. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/LICENSE +21 -21
  418. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.md +5 -5
  419. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.zh.md +5 -5
  420. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
  421. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/package.json +20 -20
  422. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
  423. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
  424. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/LICENSE +21 -21
  425. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.md +27 -27
  426. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.zh.md +29 -29
  427. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
  428. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
  429. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/package.json +25 -25
  430. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  431. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/LICENSE +21 -21
  432. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.md +13 -13
  433. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.zh.md +13 -13
  434. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  435. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/package.json +21 -21
  436. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  437. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/LICENSE +21 -21
  438. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.md +13 -13
  439. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.zh.md +13 -13
  440. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  441. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/package.json +21 -21
  442. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  443. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/LICENSE +21 -21
  444. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.md +13 -13
  445. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.zh.md +13 -13
  446. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  447. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/package.json +21 -21
  448. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  449. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/LICENSE +21 -21
  450. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.md +42 -42
  451. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.zh.md +42 -42
  452. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  453. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  454. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  455. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/package.json +20 -20
  456. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  457. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/LICENSE +21 -21
  458. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.md +13 -13
  459. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.zh.md +13 -13
  460. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  461. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/package.json +21 -21
  462. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  463. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/LICENSE +21 -21
  464. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.md +13 -13
  465. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.zh.md +13 -13
  466. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  467. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/package.json +21 -21
@@ -3,29 +3,35 @@ import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agen
3
3
  import { isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill';
4
4
  import { createUserMessage, isTokenDelta, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
5
5
  import { runSideQuestion, wrapSideQuestion } from './sideQuestion.js';
6
+ import { collectRecentActivity, parseRecapResponse, RECAP_RECENT_CHARS, wrapRecapPrompt } from './recap.js';
7
+ import { SESSION_COLOR_NAMES } from '../cc/sessionColors.js';
6
8
  import { SessionId } from '@deepseek-ai/dsh-session';
7
9
  import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
8
10
  import { loadBaselineInstructions } from '@deepseek-ai/dsh-agent-instructions';
9
11
  import { extname, isAbsolute, join } from 'node:path';
10
12
  import { completeCommands, HIDDEN_COMMAND_NAMES, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
11
13
  import { clearResumeTarget, forgetSession, readResumeTarget, touchSession, writeResumeTarget } from '../sessionHistory.js';
12
- import { appendSessionTitle, deleteSessionLog, ensureLegacySessionEventTypes, sessionsRoots } from './compat/index.js';
13
- import { listSummaries, locateSession, noteBranch, previewSession, } from './sessions/index.js';
14
+ import { appendSessionTitle, defaultMaxScanned, deleteSessionLog, ensureLegacySessionEventTypes, readSessionEventsFromFile, readSessionEventsFromLog, sessionsRoots } from './compat/index.js';
15
+ import { buildSessionTree, forkTarget, liveTailWindow, rewindTarget, turnUserText, } from './sessionTree.js';
16
+ import { resolveDshProfileName } from '../update.js';
17
+ import { listSummaries, locateSession, noteBranch, previewSession, readHeader, } from './sessions/index.js';
14
18
  import { writeActivityFrames } from '../activityPrefs.js';
15
19
  import { isPathLikeQuery, rankFileCandidates } from '../utils/fileSuggestions.js';
16
20
  import { readEffortPref, writeEffortPref } from '../effortPrefs.js';
17
21
  import { readModelPref, writeModelPref } from '../modelPrefs.js';
18
22
  import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from '../modelRoute.js';
19
23
  import { readPresetPref, writePresetPref } from '../presetPrefs.js';
20
- import { composePreset, resolvePersistedPreset, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
21
- import { isPresetName } from '../components/activityFrames.js';
24
+ import { composePreset, resolvePersistedPreset, resolvePersistedRoute, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
25
+ import { isPresetName, PRESET_NAMES } from '../components/activityFrames.js';
22
26
  import { existsSync, statSync, writeFileSync } from 'node:fs';
23
27
  import { logForDebugging } from '../utils/debug.js';
24
28
  import { homeDir, LEGACY_DATA_DIR } from '../utils/paths.js';
25
29
  import { extractMentions } from '../utils/mentions.js';
26
- import { t } from '../i18n.js';
30
+ import { getLang, LANGS, t } from '../i18n.js';
31
+ import { AUTO_THEME_NAME, THEME_NAMES } from '../theme.js';
32
+ import { listCustomThemes } from '../customTheme.js';
27
33
  import { modeDisplayName, resolveSessionModes } from '../sessionModes.js';
28
- import { normalizeStatusBar, normalizeToolBackground } from '../tuiDisplayPrefs.js';
34
+ import { normalizeScrollGutter, normalizeStatusBar, normalizeToolBackground } from '../tuiDisplayPrefs.js';
29
35
  import { SubagentActivityStore } from './subagents.js';
30
36
  import { ActivityTracker } from 'dsh-working-activity/status';
31
37
  import { featureOn } from 'dsh-working-activity/config';
@@ -328,6 +334,72 @@ function toolResultText(event) {
328
334
  return '';
329
335
  return block.content.map(item => item.type === 'text' ? item.text : '').join('').trim();
330
336
  }
337
+ /** Phase badge for the harness goal card — mirrors the panel's PhaseBadge. */
338
+ const GOAL_RESULT_BADGE = {
339
+ active: '● active',
340
+ paused: '⏸ paused',
341
+ blocked: '⛔ blocked',
342
+ complete: '✓ complete',
343
+ };
344
+ /**
345
+ * Summary cards for the harness's goal/todo tools. Their results are machine
346
+ * JSON (`{"goal":{…}}` / `{"todos":[…]}`) that would otherwise dump under the
347
+ * tool card as a raw `⎿ {"goal":…}` line. Matched by tool-name substring AND
348
+ * payload shape, so unrelated tools and plain-text results pass through to
349
+ * the registry/raw-text path untouched. Runs on the live stream and replay.
350
+ */
351
+ function harnessToolResultView(name, data) {
352
+ const lower = name.toLowerCase();
353
+ const isGoalTool = lower.includes('goal');
354
+ const isTodoTool = lower.includes('todo');
355
+ if (!isGoalTool && !isTodoTool)
356
+ return undefined;
357
+ const block = data.message.content[0];
358
+ if (block === undefined || block.type !== 'tool-result')
359
+ return undefined;
360
+ const text = block.content.map(item => item.type === 'text' ? item.text : '').join('').trim();
361
+ if (text === '' || !text.startsWith('{'))
362
+ return undefined;
363
+ let parsed;
364
+ try {
365
+ parsed = JSON.parse(text);
366
+ }
367
+ catch {
368
+ return undefined;
369
+ }
370
+ if (typeof parsed !== 'object' || parsed === null)
371
+ return undefined;
372
+ const record = parsed;
373
+ if (isGoalTool && typeof record.goal === 'object' && record.goal !== null) {
374
+ const goal = record.goal;
375
+ if (typeof goal.objective === 'string' && typeof goal.phase === 'string') {
376
+ const badge = GOAL_RESULT_BADGE[goal.phase] ?? goal.phase;
377
+ const rounds = typeof goal.roundsStarted === 'number' && typeof goal.maxGoalRounds === 'number'
378
+ ? ` · ${goal.roundsStarted}/${goal.maxGoalRounds}`
379
+ : '';
380
+ const activation = typeof record.activation === 'string' ? ` · ${record.activation}` : '';
381
+ const lines = [`🎯 ${goal.objective}`, `${badge}${rounds}${activation}`];
382
+ const blocked = goal.blockedReason?.message;
383
+ if (typeof blocked === 'string')
384
+ lines.push(`⛔ ${blocked}`);
385
+ return { card: 'generic', content: lines.map(line => ({ type: 'text', text: line })) };
386
+ }
387
+ }
388
+ if (isTodoTool && Array.isArray(record.todos)) {
389
+ const rows = record.todos;
390
+ const done = rows.filter(row => row.status === 'completed').length;
391
+ const lines = [`todos ✓ ${done}/${rows.length}`];
392
+ for (const row of rows) {
393
+ if (row.status !== 'in_progress' || typeof row.content !== 'string')
394
+ continue;
395
+ lines.push(`● ${row.content}`);
396
+ if (lines.length >= 4)
397
+ break;
398
+ }
399
+ return { card: 'generic', content: lines.map(line => ({ type: 'text', text: line })) };
400
+ }
401
+ return undefined;
402
+ }
331
403
  function toolErrorText(event) {
332
404
  const failure = event.data.error;
333
405
  if (failure === undefined)
@@ -433,8 +505,7 @@ export function createChannel(ctx, initialAgent, options) {
433
505
  try {
434
506
  runtime.interrupt(target, { kind: 'ancestor', agent });
435
507
  subagentStore.onCancelled(agentId, 'interrupted');
436
- state.subagents = subagentStore.snapshot();
437
- syncSubagentRows();
508
+ syncSubagentsNow();
438
509
  state.emit();
439
510
  return true;
440
511
  }
@@ -467,9 +538,12 @@ export function createChannel(ctx, initialAgent, options) {
467
538
  /**
468
539
  * Sync subagentStore state into ChatRows (insert/update in state.rows).
469
540
  * Called whenever subagent state changes (spawned/completed/failed/output).
541
+ * Accepts a caller-taken snapshot to avoid the double copy on the hot path
542
+ * (session/event fires per subagent token: snapshot here + snapshot in the
543
+ * caller = two full state copies before emitStream's 16ms throttle).
470
544
  */
471
- const syncSubagentRows = () => {
472
- const snapshot = subagentStore.snapshot();
545
+ const syncSubagentRows = (preSnapshot) => {
546
+ const snapshot = preSnapshot ?? subagentStore.snapshot();
473
547
  for (const sub of snapshot) {
474
548
  let row = subagentRowsByAgentId.get(sub.agentId);
475
549
  if (!row) {
@@ -546,6 +620,32 @@ export function createChannel(ctx, initialAgent, options) {
546
620
  const listeners = new Set();
547
621
  /** True while a frame-aligned stream notification is pending (emitStream). */
548
622
  let streamNotifyScheduled = false;
623
+ /** True while subagent assistant/chunk deltas have deferred their
624
+ * snapshot+projection to the frame-aligned flush (emitStream's timer).
625
+ * Chunks arrive at token rate (100-300 events/s) and the projection is a
626
+ * full deep state copy (SubagentActivityStore.snapshot) plus a SubagentRow
627
+ * rebuild per tracked agent — running that per token sits BEFORE
628
+ * emitStream's 16ms coalescing and defeats it. Non-chunk events
629
+ * (tool/call, subagent/end, interrupt) project immediately and clear
630
+ * this flag, so lifecycle transitions stay synchronous. */
631
+ let subagentStreamDirty = false;
632
+ /** Deferred projection for the frame-aligned flush: runs INSIDE the
633
+ * emitStream timer, before listeners wake, so React always reads fully
634
+ * projected rows. No-op unless a chunk marked the projection dirty. */
635
+ const flushSubagentStream = () => {
636
+ if (!subagentStreamDirty)
637
+ return;
638
+ subagentStreamDirty = false;
639
+ state.subagents = subagentStore.snapshot();
640
+ syncSubagentRows(state.subagents);
641
+ };
642
+ /** Immediate projection; supersedes any pending deferred flush (the fresh
643
+ * snapshot already contains everything the deferred pass would project). */
644
+ const syncSubagentsNow = () => {
645
+ subagentStreamDirty = false;
646
+ state.subagents = subagentStore.snapshot();
647
+ syncSubagentRows(state.subagents);
648
+ };
549
649
  // foldRows incremental cursor (see foldRows): rows only append past the
550
650
  // fold line, so each pass touches only newly-eligible rows.
551
651
  const foldCursor = { rows: null, index: 0 };
@@ -770,6 +870,79 @@ export function createChannel(ctx, initialAgent, options) {
770
870
  ctx.logger.warn('dsh-tui: tui/session-switched dispatch failed: %o', error);
771
871
  }
772
872
  };
873
+ /**
874
+ * Swap the live agent for a freshly created fork (rewindTo and the session
875
+ * tree's rewindToNode share this tail): reset every session-scoped
876
+ * projection, replay the fork's seed into a fresh transcript (tokens/
877
+ * spinner counters land back at the rewind point, matching the fork),
878
+ * rebind subscriptions to the new agent, and free the replaced handle.
879
+ * Returns the source session's id (for the session-switched notification).
880
+ */
881
+ const adoptForkedAgent = (handle, seed, agentPreset, childId) => {
882
+ // Replay the forked history into a fresh transcript (tokens/spinner
883
+ // counters land back at the rewind point, matching the fork).
884
+ streaming = undefined;
885
+ reasoning = undefined;
886
+ // Stale sealed/thinking bookkeeping belongs to the OLD agent's rows;
887
+ // keep it out of the next turn's settle logs and revive cache.
888
+ sealedReasoning.length = 0;
889
+ lastReasoningRow = undefined;
890
+ toolCards.clear();
891
+ nextRowId = 0;
892
+ state.rows.length = 0;
893
+ // Goal/todo/title are session-scoped; the replay re-derives them for
894
+ // the session being entered (or leaves them empty).
895
+ state.todos = [];
896
+ // Queued-but-undelivered messages live in the OLD agent's inbox; the
897
+ // swap must drop their previews or they linger forever (unretirable —
898
+ // retire events are filtered to the new agent, unwithdrawable — the
899
+ // new inbox never heard of them).
900
+ state.pending = [];
901
+ state.goal = undefined;
902
+ state.sessionTitle = '';
903
+ state.sessionColor = '';
904
+ state.tokens = { input: 0, output: 0 };
905
+ state.responseChars = 0;
906
+ state.activeToolCount = 0;
907
+ state.lastUserText = '';
908
+ state.working = false;
909
+ state.spinnerMode = 'requesting';
910
+ state.status = handle.agent.status;
911
+ state.agentId = handle.agent.id;
912
+ state.agentPreset = agentPreset;
913
+ state.tps = undefined;
914
+ state.tpsSamples = [];
915
+ state.lastUsage = undefined;
916
+ state.workingActivity = undefined;
917
+ state.contextSegments = {
918
+ system: 0,
919
+ prompt: 0,
920
+ assistant: 0,
921
+ thinking: 0,
922
+ tools: 0,
923
+ };
924
+ replayEvents(seed);
925
+ settleStreaming();
926
+ // A seed ending mid-turn replays a turn/start that set working=true;
927
+ // the boot path resets this after replay — mirror it here so an idle
928
+ // rewound agent doesn't sit with a live spinner (a still-running
929
+ // agent re-asserts on its next event).
930
+ state.working = handle.agent.status === 'running';
931
+ // Rebind subscriptions to the new agent, then free the old one.
932
+ const oldHandle = currentHandle;
933
+ const sourceSessionId = String(agent.session.id);
934
+ agent = handle.agent;
935
+ currentHandle = handle;
936
+ bindAgent();
937
+ refreshCommandList();
938
+ void refreshLoadedContext();
939
+ void refreshSkillCommands();
940
+ // The forked session (rewind) becomes the most recently used.
941
+ touchSession(childId);
942
+ state.emit();
943
+ void oldHandle?.dispose().catch(() => { });
944
+ return sourceSessionId;
945
+ };
773
946
  /** Monotonic token: only the latest `interruptAndDeliver` re-queues, so a
774
947
  * second interrupt while the abort settles cannot double-deliver. */
775
948
  let interruptSeq = 0;
@@ -1136,12 +1309,86 @@ export function createChannel(ctx, initialAgent, options) {
1136
1309
  // it is keyed by cwd so a /workspace switch or resumed session never reuses
1137
1310
  // another directory's listing.
1138
1311
  const fileCandidateCache = { cwd: '', load: undefined };
1312
+ // `/model <provider/id>` completion: the model catalog is async (one llm
1313
+ // listModels per provider), so the first keystrokes that could be heading
1314
+ // for /model warm a session-lifetime cache — the shared promise dedupes
1315
+ // concurrent triggers, children() synchronously serves whatever has landed,
1316
+ // and the arrival state.emit() reopens the menu mid-typing. switchModel's
1317
+ // success path drops the cache so the [current] tag re-resolves against
1318
+ // the new route.
1319
+ const modelNodeCache = {
1320
+ nodes: undefined,
1321
+ load: undefined,
1322
+ };
1323
+ const warmModelNodes = () => {
1324
+ if (modelNodeCache.load !== undefined)
1325
+ return;
1326
+ modelNodeCache.load = state.listModels().then((list) => {
1327
+ modelNodeCache.nodes = list.map((model) => ({
1328
+ name: `${model.provider}/${model.id}`,
1329
+ description: model.name,
1330
+ ...(state.provider === model.provider && state.model === model.id
1331
+ ? { tag: 'current' }
1332
+ : {}),
1333
+ }));
1334
+ state.emit();
1335
+ }).catch(() => {
1336
+ // listModels already swallows per-provider failures; this only fires
1337
+ // when the llm service shape itself is missing — settle on an empty
1338
+ // menu rather than retrying on every keystroke.
1339
+ modelNodeCache.nodes = [];
1340
+ });
1341
+ };
1342
+ // `/preset <id>` completion: same warm-cache pattern as models. The
1343
+ // current/default tags resolve at children() time (sync state reads), so
1344
+ // no cache invalidation is needed on switch.
1345
+ const presetOptionCache = {
1346
+ list: undefined,
1347
+ load: undefined,
1348
+ };
1349
+ const warmPresetOptions = () => {
1350
+ if (presetOptionCache.load !== undefined)
1351
+ return;
1352
+ presetOptionCache.load = state.listPresets().then((list) => {
1353
+ presetOptionCache.list = list;
1354
+ state.emit();
1355
+ }).catch(() => {
1356
+ presetOptionCache.list = [];
1357
+ });
1358
+ };
1359
+ // `/effort <id>` completion: state.effortLevels is the sync vocabulary
1360
+ // (populated on route changes); when still unknown, one best-effort
1361
+ // resolveEfforts warms it. `tried` caps the retry — resolveEfforts
1362
+ // notifies on hard errors, so keystroke-time retries would spam.
1363
+ const effortWarm = { tried: false };
1364
+ const warmEffortLevels = () => {
1365
+ if (state.effortLevels !== undefined || effortWarm.tried)
1366
+ return;
1367
+ effortWarm.tried = true;
1368
+ void resolveEfforts().then((resolved) => {
1369
+ if (resolved === 'unavailable' || resolved === 'error')
1370
+ return;
1371
+ effortWarm.tried = false;
1372
+ state.emit();
1373
+ }).catch(() => { });
1374
+ };
1139
1375
  const state = {
1140
1376
  effortLevels: undefined,
1141
1377
  version: 0,
1142
1378
  rows: [],
1143
1379
  status: 'starting',
1144
1380
  sessionTitle: '',
1381
+ sessionColor: '',
1382
+ get autoRecapOnOpen() {
1383
+ // Live read (not a boot snapshot): a /settings change applies on the
1384
+ // next session switch. No settings service → off (framework absent,
1385
+ // e.g. headless fixtures — nothing to configure and no llm route).
1386
+ const settings = ctx.get('settings');
1387
+ if (settings === undefined)
1388
+ return false;
1389
+ const ns = settings.describe({ redactSecrets: true }).find(entry => entry.ns === 'dsh-tui');
1390
+ return ns?.value?.recapOnOpen !== false;
1391
+ },
1145
1392
  agentId: agent.id,
1146
1393
  model: options.model,
1147
1394
  provider: options.provider,
@@ -1166,9 +1413,16 @@ export function createChannel(ctx, initialAgent, options) {
1166
1413
  modeIndex: 0,
1167
1414
  workingActivity: undefined,
1168
1415
  activityFrames: options.activityFrames,
1416
+ configuredProvider: options.configuredProvider,
1417
+ configuredModel: options.configuredModel,
1418
+ configuredPreset: options.configuredPreset,
1419
+ configuredActivityFrames: options.configuredActivityFrames,
1420
+ configuredLang: options.configuredLang,
1169
1421
  diffLayout: options.diffLayout ?? 'auto',
1170
1422
  thinkingFold: options.thinkingFold ?? 'preview',
1171
1423
  toolBackground: normalizeToolBackground(options.toolBackground),
1424
+ scrollGutter: normalizeScrollGutter(options.scrollGutter),
1425
+ promptSessionLabel: options.promptSessionLabel === true,
1172
1426
  statusBar: normalizeStatusBar(options.statusBar),
1173
1427
  whale: options.whale !== false,
1174
1428
  minimal: options.minimal === true,
@@ -1181,7 +1435,106 @@ export function createChannel(ctx, initialAgent, options) {
1181
1435
  pending: [],
1182
1436
  commandList: LOCAL_COMMANDS,
1183
1437
  commandCompletions(input) {
1438
+ // Warm the async vocabularies as soon as the input could be heading
1439
+ // for their commands (`/m`, `/pre`, …) — by the time a trailing space
1440
+ // asks children() for nodes, the fetch has usually landed.
1441
+ const head = input.slice(1).split(/[\t ]/)[0]?.toLowerCase() ?? '';
1442
+ if (head !== '') {
1443
+ if ('model'.startsWith(head))
1444
+ warmModelNodes();
1445
+ if ('preset'.startsWith(head))
1446
+ warmPresetOptions();
1447
+ if ('effort'.startsWith(head))
1448
+ warmEffortLevels();
1449
+ }
1184
1450
  return completeCommands(input, state.commandList, (path) => {
1451
+ if (path.length === 1 && path[0] === 'model') {
1452
+ // provider/id specs, current model tagged; see modelNodeCache.
1453
+ warmModelNodes();
1454
+ return modelNodeCache.nodes ?? [];
1455
+ }
1456
+ if (path.length === 1 && path[0] === 'lang') {
1457
+ return [
1458
+ { name: 'status', description: 'Show the current UI language', descriptionKey: 'sugg-status-desc' },
1459
+ ...LANGS.map((lang) => ({
1460
+ name: lang,
1461
+ description: `Switch the UI language to ${lang}`,
1462
+ descriptionKey: lang === 'zh' ? 'sugg-lang-zh-desc' : 'sugg-lang-en-desc',
1463
+ ...(getLang() === lang ? { tag: 'current' } : {}),
1464
+ })),
1465
+ ];
1466
+ }
1467
+ if (path.length === 1 && path[0] === 'theme') {
1468
+ return [
1469
+ { name: 'status', description: 'Show the current theme', descriptionKey: 'sugg-status-desc' },
1470
+ { name: AUTO_THEME_NAME, description: 'Follow the terminal background', descriptionKey: 'sugg-theme-auto-desc' },
1471
+ ...THEME_NAMES.map((name) => ({
1472
+ name,
1473
+ description: `Built-in theme ${name}`,
1474
+ descriptionKey: 'sugg-theme-builtin-desc',
1475
+ })),
1476
+ ...listCustomThemes()
1477
+ .filter((spec) => spec.name !== AUTO_THEME_NAME)
1478
+ .map((spec) => ({
1479
+ name: spec.name,
1480
+ description: `User theme (${spec.base} base)`,
1481
+ descriptionKey: 'sugg-theme-user-desc',
1482
+ })),
1483
+ ];
1484
+ }
1485
+ if (path.length === 1 && path[0] === 'color') {
1486
+ return [
1487
+ { name: 'status', description: 'Show the current session color', descriptionKey: 'sugg-status-desc' },
1488
+ { name: 'reset', description: 'Clear the session color', descriptionKey: 'sugg-color-reset-desc' },
1489
+ ...SESSION_COLOR_NAMES.map((name) => ({
1490
+ name,
1491
+ description: 'Session accent color',
1492
+ descriptionKey: 'sugg-color-name-desc',
1493
+ ...(state.sessionColor === name ? { tag: 'current' } : {}),
1494
+ })),
1495
+ ];
1496
+ }
1497
+ if (path.length === 1 && path[0] === 'effort') {
1498
+ warmEffortLevels();
1499
+ return [
1500
+ { name: 'status', description: 'Show the current reasoning effort', descriptionKey: 'sugg-status-desc' },
1501
+ ...(state.effortLevels ?? []).map((id) => ({
1502
+ name: id,
1503
+ description: 'Reasoning effort level',
1504
+ descriptionKey: 'sugg-effort-level-desc',
1505
+ ...(state.reasoningEffort === id ? { tag: 'current' } : {}),
1506
+ })),
1507
+ ];
1508
+ }
1509
+ if (path.length === 1 && path[0] === 'preset') {
1510
+ warmPresetOptions();
1511
+ return [
1512
+ { name: 'status', description: 'Show the current agent preset', descriptionKey: 'sugg-status-desc' },
1513
+ ...(presetOptionCache.list ?? []).map((preset) => ({
1514
+ name: preset.id,
1515
+ description: preset.description ?? preset.name ?? preset.id,
1516
+ ...(preset.id === state.agentPreset
1517
+ ? { tag: 'current' }
1518
+ : preset.isDefault
1519
+ ? { tag: 'default' }
1520
+ : {}),
1521
+ })),
1522
+ ];
1523
+ }
1524
+ if (path.length === 1 && path[0] === 'activity') {
1525
+ return [
1526
+ { name: 'status', description: 'Show the current activity preset', descriptionKey: 'sugg-status-desc' },
1527
+ { name: 'frames', description: 'List or switch frame presets', descriptionKey: 'sugg-activity-frames-desc' },
1528
+ ];
1529
+ }
1530
+ if (path.length === 2 && path[0] === 'activity' && path[1] === 'frames') {
1531
+ return PRESET_NAMES.map((name) => ({
1532
+ name,
1533
+ description: 'Animation frame preset',
1534
+ descriptionKey: 'sugg-activity-frame-desc',
1535
+ ...(state.activityFrames === name ? { tag: 'current' } : {}),
1536
+ }));
1537
+ }
1185
1538
  if (path.length === 1 && path[0] === 'workspace') {
1186
1539
  const builtins = [
1187
1540
  { name: 'resume', description: 'Switch to another workspace', descriptionKey: 'cmd-desc-workspace-resume' },
@@ -1200,6 +1553,21 @@ export function createChannel(ctx, initialAgent, options) {
1200
1553
  })),
1201
1554
  ];
1202
1555
  }
1556
+ if (path.length === 1 && path[0] === 'permission') {
1557
+ // Sandbox-preset vocabulary of the `/permission` picker — Tab
1558
+ // completes it for keyboard users, Enter still dispatches.
1559
+ return [
1560
+ { name: 'read-only', description: 'Read-only session: no file writes, no commands', descriptionKey: 'permission-preset-readonly-desc' },
1561
+ { name: 'workspace-write', description: 'Read/write inside the workspace; writes need a prior read', descriptionKey: 'permission-preset-workspace-write-desc' },
1562
+ { name: 'danger-full-access', description: 'Unrestricted access, no approvals', descriptionKey: 'permission-preset-full-access-desc' },
1563
+ ];
1564
+ }
1565
+ if (path.length === 1 && path[0] === 'plan') {
1566
+ return [
1567
+ { name: 'on', description: 'Enter plan mode: read-only, plan before acting', descriptionKey: 'plan-mode-on-desc' },
1568
+ { name: 'off', description: 'Exit plan mode, back to normal execution', descriptionKey: 'plan-mode-off-desc' },
1569
+ ];
1570
+ }
1203
1571
  return commandTrees?.children(path) ?? [];
1204
1572
  });
1205
1573
  },
@@ -1240,6 +1608,9 @@ export function createChannel(ctx, initialAgent, options) {
1240
1608
  streamNotifyScheduled = true;
1241
1609
  const timer = setTimeout(() => {
1242
1610
  streamNotifyScheduled = false;
1611
+ // Deferred subagent projection rides this same frame: flush BEFORE
1612
+ // the listeners wake so React reads fully projected rows.
1613
+ flushSubagentStream();
1243
1614
  foldRows(state.rows, MAX_ROWS, foldCursor);
1244
1615
  for (const listener of listeners)
1245
1616
  listener();
@@ -1509,67 +1880,9 @@ export function createChannel(ctx, initialAgent, options) {
1509
1880
  catch (error) {
1510
1881
  state.notify(t('rewind-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
1511
1882
  }
1512
- // Replay the forked history into a fresh transcript (tokens/spinner
1513
- // counters land back at the rewind point, matching the fork).
1514
- streaming = undefined;
1515
- reasoning = undefined;
1516
- // Stale sealed/thinking bookkeeping belongs to the OLD agent's rows;
1517
- // keep it out of the next turn's settle logs and revive cache.
1518
- sealedReasoning.length = 0;
1519
- lastReasoningRow = undefined;
1520
- toolCards.clear();
1521
- nextRowId = 0;
1522
- state.rows.length = 0;
1523
- // Goal/todo/title are session-scoped; the replay re-derives them for
1524
- // the session being entered (or leaves them empty).
1525
- state.todos = [];
1526
- // Queued-but-undelivered messages live in the OLD agent's inbox; the
1527
- // swap must drop their previews or they linger forever (unretirable —
1528
- // retire events are filtered to the new agent, unwithdrawable — the
1529
- // new inbox never heard of them).
1530
- state.pending = [];
1531
- state.goal = undefined;
1532
- state.sessionTitle = '';
1533
- state.tokens = { input: 0, output: 0 };
1534
- state.responseChars = 0;
1535
- state.activeToolCount = 0;
1536
- state.lastUserText = '';
1537
- state.working = false;
1538
- state.spinnerMode = 'requesting';
1539
- state.status = handle.agent.status;
1540
- state.agentId = handle.agent.id;
1541
- state.agentPreset = rewindComposed.agentPreset;
1542
- state.tps = undefined;
1543
- state.tpsSamples = [];
1544
- state.lastUsage = undefined;
1545
- state.workingActivity = undefined;
1546
- state.contextSegments = {
1547
- system: 0,
1548
- prompt: 0,
1549
- assistant: 0,
1550
- thinking: 0,
1551
- tools: 0,
1552
- };
1553
- replayEvents(seed);
1554
- settleStreaming();
1555
- // A seed ending mid-turn replays a turn/start that set working=true;
1556
- // the boot path resets this after replay — mirror it here so an idle
1557
- // rewound agent doesn't sit with a live spinner (a still-running
1558
- // agent re-asserts on its next event).
1559
- state.working = handle.agent.status === 'running';
1560
- // Rebind subscriptions to the new agent, then free the old one.
1561
- const oldHandle = currentHandle;
1562
- const sourceSessionId = String(agent.session.id);
1563
- agent = handle.agent;
1564
- currentHandle = handle;
1565
- bindAgent();
1566
- refreshCommandList();
1567
- void refreshLoadedContext();
1568
- void refreshSkillCommands();
1569
- // The forked session (rewind) becomes the most recently used.
1570
- touchSession(childId);
1571
- state.emit();
1572
- void oldHandle?.dispose().catch(() => { });
1883
+ // Swap the live agent for the fork (shared with rewindToNode): replay
1884
+ // the seed, rebind, and free the replaced handle.
1885
+ const sourceSessionId = adoptForkedAgent(handle, seed, rewindComposed.agentPreset, childId);
1573
1886
  // Decision-event pair around the completed rewind: `tui/rewind-done`
1574
1887
  // (the first non-empty string is toasted as the post-rewind summary,
1575
1888
  // e.g. a plugin reporting restored files) and the generic
@@ -1609,6 +1922,685 @@ export function createChannel(ctx, initialAgent, options) {
1609
1922
  notifySessionSwitched('rewind', String(childId), sourceSessionId);
1610
1923
  return row.text;
1611
1924
  },
1925
+ async buildSessionTree() {
1926
+ const persistence = ctx.get('sessionPersistence');
1927
+ if (!persistence) {
1928
+ state.notify(t('tree-unavailable'), { color: 'error' });
1929
+ return null;
1930
+ }
1931
+ // Pin the live session snapshot NOW: every await below (list/inspect)
1932
+ // is a window in which a fire-and-forget switch (/new, /resume,
1933
+ // /model) can swap `agent`. Reading agent.session piecemeal would
1934
+ // stitch the NEW session's events under the OLD session's id — a
1935
+ // confirm would then rewind from the wrong persisted log. Everything
1936
+ // below reads this snapshot, and the result is discarded if the live
1937
+ // session moved on before the build finished.
1938
+ const liveSession = agent.session;
1939
+ const currentId = String(liveSession.id);
1940
+ // Same enumerate as the /resume listing (snapshots when the backend
1941
+ // offers revisions, plain list otherwise), each header narrowed through
1942
+ // the sessions reader — one malformed header costs that session its
1943
+ // metadata, never the whole tree. `raw` stays the backend's own header
1944
+ // object for locate() below.
1945
+ let listed = [];
1946
+ try {
1947
+ if (typeof persistence.listSnapshots === 'function') {
1948
+ const snapshots = await persistence.listSnapshots();
1949
+ listed = snapshots.flatMap(snapshot => {
1950
+ const raw = snapshot?.header;
1951
+ const header = readHeader(raw);
1952
+ return header === undefined ? [] : [{ header, raw }];
1953
+ });
1954
+ }
1955
+ else if (typeof persistence.list === 'function') {
1956
+ const headers = await persistence.list();
1957
+ listed = headers.flatMap(raw => {
1958
+ const header = readHeader(raw);
1959
+ return header === undefined ? [] : [{ header, raw }];
1960
+ });
1961
+ }
1962
+ }
1963
+ catch {
1964
+ // A listing failure degrades the tree to the live session only.
1965
+ }
1966
+ // Same cwd scoping as /resume (Claude Code's project dimension): forks
1967
+ // inherit cwd, so the family never crosses projects — and the match is
1968
+ // the project-aware one /resume uses, so a pre-upgrade subdirectory
1969
+ // path, Windows separators, or a case variant on one header cannot
1970
+ // quietly amputate the ancestors and siblings it records. Subagent
1971
+ // child sessions carry parentSession too, but they are delegation
1972
+ // artifacts, not rewind branches — exclude them from the family.
1973
+ const local = listed.filter(entry => sessionCwdMatches(state.cwd, entry.header.cwd ?? '') &&
1974
+ entry.header.origin !== 'subagent' &&
1975
+ (entry.header.delegationDepth ?? 0) === 0);
1976
+ const headerById = new Map(local.map(entry => [entry.header.id, entry]));
1977
+ // The live session's header may not be materialized in list() yet
1978
+ // (the jsonl backend writes on first append) — overlay the in-memory
1979
+ // header so the ancestor walk below still finds a fresh fork's parent.
1980
+ const liveMeta = liveSession.header;
1981
+ if (!headerById.has(currentId) && liveMeta !== undefined) {
1982
+ headerById.set(currentId, { header: readHeader(liveMeta) ?? { id: currentId, cwd: undefined, createdAt: undefined, parentSession: undefined, origin: undefined, delegationDepth: undefined, seedLength: undefined, agentPreset: undefined }, raw: liveMeta });
1983
+ }
1984
+ // Family = the live session's ancestor chain PLUS every descendant of
1985
+ // its topmost known ancestor (siblings and cousins included).
1986
+ const childrenByParent = new Map();
1987
+ for (const entry of local) {
1988
+ if (entry.header.parentSession === undefined)
1989
+ continue;
1990
+ const list = childrenByParent.get(entry.header.parentSession);
1991
+ if (list === undefined)
1992
+ childrenByParent.set(entry.header.parentSession, [entry.header.id]);
1993
+ else
1994
+ list.push(entry.header.id);
1995
+ }
1996
+ const ancestorIds = [];
1997
+ {
1998
+ const visited = new Set([currentId]);
1999
+ let cursor = headerById.get(currentId);
2000
+ while (cursor?.header.parentSession !== undefined) {
2001
+ const parentId = cursor.header.parentSession;
2002
+ if (visited.has(parentId))
2003
+ break;
2004
+ visited.add(parentId);
2005
+ if (!headerById.has(parentId))
2006
+ break;
2007
+ ancestorIds.push(parentId);
2008
+ cursor = headerById.get(parentId);
2009
+ }
2010
+ }
2011
+ // BFS from the topmost ancestor. The scan must NOT be gated by family
2012
+ // membership: ancestor-chain nodes are already in the family, and
2013
+ // skipping them here would never enumerate their other children —
2014
+ // siblings/cousins forking off a MIDDLE ancestor would be lost.
2015
+ const family = new Set([currentId, ...ancestorIds]);
2016
+ {
2017
+ const scanned = new Set();
2018
+ const queue = [ancestorIds.at(-1) ?? currentId];
2019
+ while (queue.length > 0) {
2020
+ const id = queue.shift();
2021
+ if (scanned.has(id))
2022
+ continue;
2023
+ scanned.add(id);
2024
+ family.add(id);
2025
+ for (const child of childrenByParent.get(id) ?? []) {
2026
+ queue.push(child);
2027
+ }
2028
+ }
2029
+ }
2030
+ // Processing order is TOPOLOGICAL (a parent before its children): the
2031
+ // coverage bookkeeping below — which seq range each chain already
2032
+ // shows — feeds the next read's inherited-prefix skip, so a parent
2033
+ // must be read before its forks. Within each sibling group the live
2034
+ // chain wins, then newest first (the same priority the read budget
2035
+ // always had).
2036
+ const ancestorSet = new Set(ancestorIds);
2037
+ const priorityOf = (a, b) => {
2038
+ const aChain = a === currentId || ancestorSet.has(a);
2039
+ const bChain = b === currentId || ancestorSet.has(b);
2040
+ if (aChain !== bChain)
2041
+ return aChain ? -1 : 1;
2042
+ return (headerById.get(b)?.header.createdAt ?? 0) - (headerById.get(a)?.header.createdAt ?? 0);
2043
+ };
2044
+ const kidsOf = new Map();
2045
+ const familyRoots = [];
2046
+ for (const id of family) {
2047
+ const parentId = headerById.get(id)?.header.parentSession;
2048
+ if (parentId !== undefined && parentId !== id && family.has(parentId)) {
2049
+ const list = kidsOf.get(parentId);
2050
+ if (list === undefined)
2051
+ kidsOf.set(parentId, [id]);
2052
+ else
2053
+ list.push(id);
2054
+ }
2055
+ else {
2056
+ familyRoots.push(id);
2057
+ }
2058
+ }
2059
+ familyRoots.sort(priorityOf);
2060
+ for (const list of kidsOf.values())
2061
+ list.sort(priorityOf);
2062
+ const ordered = [];
2063
+ {
2064
+ const seen = new Set();
2065
+ const stack = [...familyRoots].reverse();
2066
+ while (stack.length > 0) {
2067
+ const id = stack.pop();
2068
+ if (seen.has(id))
2069
+ continue;
2070
+ seen.add(id);
2071
+ ordered.push(id);
2072
+ const kids = kidsOf.get(id);
2073
+ if (kids !== undefined) {
2074
+ for (let i = kids.length - 1; i >= 0; i--)
2075
+ stack.push(kids[i]);
2076
+ }
2077
+ }
2078
+ // Cycle-broken leftovers (corrupt parent headers) — never drop one.
2079
+ for (const id of [...family].sort(priorityOf)) {
2080
+ if (!seen.has(id))
2081
+ ordered.push(id);
2082
+ }
2083
+ }
2084
+ // Caps: the ancestor chain + live session ALWAYS stay selected — the
2085
+ // structural invariant (the live branch must reach the family root)
2086
+ // outranks the session cap, which therefore evicts only non-ancestors.
2087
+ // The event budget bounds the READ cost too: non-live logs decode
2088
+ // lazily and stop at the remaining budget (see below), the live
2089
+ // session keeps only its tail.
2090
+ const MAX_TREE_SESSIONS = 24;
2091
+ const MAX_TREE_EVENTS = 200_000;
2092
+ // The event budget alone does NOT bound read cost: skipped envelopes
2093
+ // (ignorable frames, headers) are paid for but never collected, so a
2094
+ // noisy log can return ZERO events and leave the next log a full scan
2095
+ // allowance — 23 logs × ~800k envelopes would block the TUI for
2096
+ // seconds. scanBudget caps the TOTAL envelopes inspected across all
2097
+ // logs (the reader reports its real scanned count); per-log caps
2098
+ // derive from the event budget as before, and the smaller of the two
2099
+ // applies, so one flood cannot starve every later sibling either.
2100
+ const MAX_TREE_SCANNED = defaultMaxScanned(MAX_TREE_EVENTS);
2101
+ let scanBudget = MAX_TREE_SCANNED;
2102
+ const selected = new Set();
2103
+ let slots = MAX_TREE_SESSIONS;
2104
+ for (const id of ordered) {
2105
+ const chain = id === currentId || ancestorSet.has(id);
2106
+ if (chain || slots > 0) {
2107
+ selected.add(id);
2108
+ if (!chain)
2109
+ slots -= 1;
2110
+ }
2111
+ }
2112
+ // The live session's events come from memory (its header may not be
2113
+ // materialized yet — the jsonl backend writes on first append).
2114
+ const liveHeader = headerById.get(currentId);
2115
+ const familySessions = [];
2116
+ let truncated = selected.size < family.size;
2117
+ let eventBudget = 0;
2118
+ // Coverage bookkeeping: coveredThrough(S) = the highest K such that
2119
+ // [0..K] is already displayed by S's chain or an ancestor's. A fork's
2120
+ // inherited seed prefix duplicates that range, so non-live reads SKIP
2121
+ // it (the reader's skipBelowSeq): the prefix still costs scan budget
2122
+ // (its bytes are read and parsed) but NOT the event budget — a fork
2123
+ // of a huge parent pays only for its OWN events, so two small forks
2124
+ // of a 70k-event parent both stay visible. Unreadable/unloaded
2125
+ // sessions are transparent: they claim nothing beyond what their own
2126
+ // ancestors covered, so a fork of a dead branch dedups against the
2127
+ // grandparent instead of hiding its self-contained history.
2128
+ const coveredThrough = new Map();
2129
+ for (const id of ordered) {
2130
+ if (!selected.has(id))
2131
+ continue;
2132
+ const entry = headerById.get(id);
2133
+ if (id === currentId) {
2134
+ const liveParentId = liveHeader?.header.parentSession ?? liveMeta?.parentSession;
2135
+ const liveParent = liveParentId !== undefined ? String(liveParentId) : undefined;
2136
+ const parentCovered = liveParent !== undefined
2137
+ ? (coveredThrough.get(liveParent) ?? -1)
2138
+ : -1;
2139
+ const liveEvents = liveSession.events;
2140
+ const remaining = Math.max(0, MAX_TREE_EVENTS - eventBudget);
2141
+ // The live session's in-memory log is SELF-CONTAINED: a fork's
2142
+ // events still carry the inherited seed prefix, which the parent's
2143
+ // chain already displays (and already charged to the budget).
2144
+ // Skipping it exactly like the non-live reads do keeps a live fork
2145
+ // of a huge parent from spending the whole family budget on
2146
+ // duplicated history and evicting its own siblings.
2147
+ const liveSeed = liveHeader?.header.seedLength ?? liveMeta?.seedLength;
2148
+ const skipBelow = liveParent !== undefined && liveSeed !== undefined
2149
+ ? Math.min(liveSeed, parentCovered + 1)
2150
+ : 0;
2151
+ const own = skipBelow > 0 ? liveEvents.filter(event => event.seq >= skipBelow) : liveEvents;
2152
+ // A live session larger than the remaining budget keeps its TAIL,
2153
+ // aligned to whole turns (sessionTree.liveTailWindow): leftover
2154
+ // entries of a turn whose turn/start was cut away render as
2155
+ // selectable rows that can never rewind; a window holding no
2156
+ // turn/start at all (one oversized LAST turn spans the budget)
2157
+ // retries over the earlier complete turns instead of blacking the
2158
+ // session out. Rewind itself never reads this copy (rewindToNode
2159
+ // forks the real session), so the slice only narrows what the tree
2160
+ // can display.
2161
+ const events = liveTailWindow(own, remaining);
2162
+ // Charge the KEPT tail, not the in-memory length: extraction only
2163
+ // ever touches `events`, and charging the full log would black out
2164
+ // every other family member's budget behind a discarded prefix.
2165
+ eventBudget += events.length;
2166
+ if (events.length !== own.length)
2167
+ truncated = true;
2168
+ familySessions.push({
2169
+ id,
2170
+ createdAt: liveHeader?.header.createdAt ?? liveMeta?.createdAt ?? Date.now(),
2171
+ ...(liveParent !== undefined ? { parentSession: liveParent } : {}),
2172
+ ...(liveHeader?.header.seedLength !== undefined || liveMeta?.seedLength !== undefined
2173
+ ? { seedLength: liveHeader?.header.seedLength ?? liveMeta.seedLength }
2174
+ : {}),
2175
+ events,
2176
+ live: true,
2177
+ // The in-memory log always reaches the tip (liveTailWindow trims
2178
+ // the head only), so the adopt/warning UX facts are derivable.
2179
+ tailComplete: true,
2180
+ });
2181
+ // A kept tail cut off the front connects to nothing — coverage
2182
+ // stays at the parent's (a fork of the live session re-reads the
2183
+ // hidden prefix from its own log).
2184
+ const firstKept = events.length > 0 ? events[0].seq : Number.POSITIVE_INFINITY;
2185
+ const lastKept = events.length > 0 ? events[events.length - 1].seq : -1;
2186
+ coveredThrough.set(id, firstKept <= parentCovered + 1 ? Math.max(parentCovered, lastKept) : parentCovered);
2187
+ continue;
2188
+ }
2189
+ const header = entry?.header;
2190
+ const parentId = header?.parentSession;
2191
+ const parentCovered = parentId !== undefined ? (coveredThrough.get(parentId) ?? -1) : -1;
2192
+ // Never skip past the seed prefix: events beyond it are this
2193
+ // session's OWN — no ancestor can show them. A parent that was never
2194
+ // read (evicted, or outside the family) covers nothing (skip 0).
2195
+ const skipBelow = parentId !== undefined && header?.seedLength !== undefined
2196
+ ? Math.min(header.seedLength, parentCovered + 1)
2197
+ : 0;
2198
+ const facts = {
2199
+ id,
2200
+ createdAt: header?.createdAt ?? 0,
2201
+ ...(parentId !== undefined ? { parentSession: parentId } : {}),
2202
+ ...(header?.seedLength !== undefined ? { seedLength: header.seedLength } : {}),
2203
+ };
2204
+ if (eventBudget >= MAX_TREE_EVENTS || scanBudget <= 0) {
2205
+ // Budget spent: keep the STRUCTURE — the session degrades to an
2206
+ // unloaded placeholder so its branch (and any ancestor chain
2207
+ // through it) stays visible instead of vanishing from the tree.
2208
+ truncated = true;
2209
+ familySessions.push({ ...facts, events: [], live: false, unloaded: true });
2210
+ coveredThrough.set(id, parentCovered);
2211
+ continue;
2212
+ }
2213
+ // Read-only, tolerant, bounded: the compat reader decodes frames
2214
+ // lazily and stops at the remaining event budget. Browsing the tree
2215
+ // must never REWRITE history logs (the ignorable-marking repair
2216
+ // stays on the explicit resume/rewind path), and the strict backend
2217
+ // inspect would both reject third-party event types wholesale and
2218
+ // parse chunk-heavy logs whole. Header facts come from list().
2219
+ const remaining = MAX_TREE_EVENTS - eventBudget;
2220
+ // Source precedence, all read-only:
2221
+ // 1. persistence.locate — the backend's OWN artifact resolution is
2222
+ // authoritative (custom root, workspace-key scheme). When it
2223
+ // names a path, ONLY that file is read: falling back to a
2224
+ // same-id copy under the stock root could surface a STALE log
2225
+ // from another backend configuration. A locate miss or an ABSENT
2226
+ // file falls through to inspect, never to the stock scan.
2227
+ // 2. Stock root scan — only for backends WITHOUT locate (fakes,
2228
+ // older custom implementations).
2229
+ // 3. inspect — the backend's strict read (non-file backends), with
2230
+ // the same budget enforced on what we keep — and ONLY when the
2231
+ // file read found NOTHING (undefined). A read that failed on a
2232
+ // safety cap or corruption (failed) must never escalate here:
2233
+ // inspect parses the WHOLE log up front, so falling through
2234
+ // would re-read unboundedly exactly the logs the caps exist to
2235
+ // bound (64 MiB frames, decode bombs) — degrade to a placeholder
2236
+ // instead.
2237
+ let events;
2238
+ let complete = true;
2239
+ let failed = false;
2240
+ // First seq the chosen source actually covers: the file readers start
2241
+ // at the inherited-prefix skip, inspect always hands the whole log.
2242
+ let readFrom = 0;
2243
+ // Per-log scan allowance: the usual 4×-of-remaining derivation,
2244
+ // clamped to what the tree-level scan budget still has.
2245
+ const scanAllowance = Math.min(defaultMaxScanned(remaining), scanBudget);
2246
+ const locate = persistence.locate;
2247
+ const hasLocate = typeof locate === 'function';
2248
+ if (hasLocate && entry !== undefined) {
2249
+ let locatedPath;
2250
+ try {
2251
+ const location = locate.call(persistence, entry.raw);
2252
+ // Only the jsonl kind enters the compat file layer — a foreign
2253
+ // kind's artifact is the backend's own format (inspect below).
2254
+ if (location !== null && typeof location === 'object') {
2255
+ const record = location;
2256
+ if (record.kind === 'jsonl' && typeof record.path === 'string') {
2257
+ locatedPath = record.path;
2258
+ }
2259
+ }
2260
+ }
2261
+ catch {
2262
+ // Best effort — a locate hiccup falls through to inspect.
2263
+ }
2264
+ if (locatedPath !== undefined) {
2265
+ const viaPath = readSessionEventsFromFile(locatedPath, remaining, scanAllowance, skipBelow);
2266
+ if (viaPath !== undefined) {
2267
+ scanBudget -= viaPath.scanned;
2268
+ if (viaPath.failed === true)
2269
+ failed = true;
2270
+ else {
2271
+ events = viaPath.events;
2272
+ complete = viaPath.complete;
2273
+ readFrom = skipBelow;
2274
+ }
2275
+ }
2276
+ }
2277
+ }
2278
+ else if (!hasLocate) {
2279
+ const read = readSessionEventsFromLog(id, remaining, scanAllowance, skipBelow);
2280
+ if (read !== undefined) {
2281
+ scanBudget -= read.scanned;
2282
+ if (read.failed === true)
2283
+ failed = true;
2284
+ else {
2285
+ events = read.events;
2286
+ complete = read.complete;
2287
+ readFrom = skipBelow;
2288
+ }
2289
+ }
2290
+ }
2291
+ if (!failed && events === undefined && typeof persistence.inspect === 'function') {
2292
+ try {
2293
+ const inspection = await persistence.inspect(SessionId(id));
2294
+ // inspect parses the WHOLE log up front: charge the full length
2295
+ // to the scan budget (may overdraw; the next iterations skip).
2296
+ scanBudget -= inspection.events.length;
2297
+ // Non-file backends hand back the self-contained log from seq 0:
2298
+ // the inherited-prefix skip the file readers got must apply here
2299
+ // too, or a long prefix would fill the slice and the branch's OWN
2300
+ // events — the only ones nobody else displays — would be cut.
2301
+ const all = skipBelow > 0 ? inspection.events.filter(event => event.seq >= skipBelow) : inspection.events;
2302
+ readFrom = skipBelow;
2303
+ events = all;
2304
+ if (events.length > remaining) {
2305
+ events = events.slice(0, remaining);
2306
+ complete = false;
2307
+ }
2308
+ }
2309
+ catch {
2310
+ events = undefined;
2311
+ }
2312
+ }
2313
+ if (failed || events === undefined) {
2314
+ // An unreadable log keeps the branch structure, no entries — and
2315
+ // stays transparent for coverage, so a fork of this branch dedups
2316
+ // against the grandparent instead of hiding its own history.
2317
+ familySessions.push({ ...facts, events: [], live: false, unreadable: true });
2318
+ coveredThrough.set(id, parentCovered);
2319
+ continue;
2320
+ }
2321
+ eventBudget += events.length;
2322
+ if (!complete)
2323
+ truncated = true;
2324
+ // tailComplete gates the branch-adopt target and the drop-turn
2325
+ // warning: a budget-sliced read lost the tail, and a tip computed
2326
+ // from it would fork mid-branch while claiming to keep everything.
2327
+ familySessions.push({ ...facts, events, live: false, ...(complete ? { tailComplete: true } : {}) });
2328
+ const lastRead = events.length > 0 ? events[events.length - 1].seq : -1;
2329
+ coveredThrough.set(id, readFrom <= parentCovered + 1 ? Math.max(parentCovered, lastRead) : parentCovered);
2330
+ }
2331
+ // A session swap mid-build invalidates the whole assembly (it mixes
2332
+ // the snapshot's lineage with headers listed for the OLD cwd state):
2333
+ // drop it silently — the reopened tree rebuilds on the new session.
2334
+ if (agent.session !== liveSession)
2335
+ return null;
2336
+ return buildSessionTree(familySessions, currentId, truncated);
2337
+ },
2338
+ async rewindToNode(sessionId, seq, mode = 'rewind') {
2339
+ const agents = ctx.get('agents');
2340
+ if (!agents) {
2341
+ state.notify(t('rewind-unavailable'), { color: 'error' });
2342
+ return null;
2343
+ }
2344
+ // Pin the entry-time session: the awaits below (log load, preset
2345
+ // compose, agent create) are windows in which a queued switch
2346
+ // (/new, /resume, /model) can swap `agent` — the mutation queue only
2347
+ // serializes the REPLACING entries, so the boundary and restored text
2348
+ // derive from THIS session's log and any swap along the way aborts
2349
+ // the rewind (forking or disposing whatever agent happens to be
2350
+ // current at the end would rewind the wrong session).
2351
+ const entrySession = agent.session;
2352
+ const currentId = String(entrySession.id);
2353
+ const childId = SessionId(randomUUID());
2354
+ // Source events: the live session from memory; any other family member
2355
+ // from its durable log (legacy event types registered first — the same
2356
+ // in-process compat seam as resumeTo, since load validates known types).
2357
+ let sourceEvents;
2358
+ let sourceCwd = state.cwd;
2359
+ let forkFromLive = true;
2360
+ if (sessionId === currentId) {
2361
+ sourceEvents = entrySession.events;
2362
+ }
2363
+ else {
2364
+ forkFromLive = false;
2365
+ const persistence = ctx.get('sessionPersistence');
2366
+ if (!persistence || typeof persistence.load !== 'function') {
2367
+ state.notify(t('rewind-no-persistence'), { color: 'error' });
2368
+ return null;
2369
+ }
2370
+ try {
2371
+ ensureLegacySessionEventTypes();
2372
+ const loaded = await persistence.load(SessionId(sessionId));
2373
+ sourceEvents = loaded.events;
2374
+ sourceCwd = loaded.meta.cwd ?? state.cwd;
2375
+ }
2376
+ catch (error) {
2377
+ const message = error instanceof Error ? error.message : String(error);
2378
+ state.notify(t('rewind-load-failed', { err: message }), { color: 'error' });
2379
+ return null;
2380
+ }
2381
+ }
2382
+ // DSH event order is `turn/start → user/message → … → turn/end`, and a
2383
+ // fork seed must not end inside an open turn. pi's navigateTree
2384
+ // semantics mapped onto that constraint (sessionTree.rewindTarget): a
2385
+ // USER message drops its turn — the boundary sits just before the
2386
+ // turn/start and the prompt comes back into the input for re-editing;
2387
+ // any OTHER entry keeps through its enclosing STEP — a mid-turn cut at
2388
+ // the step/end with the turn closed synthetically (DSH agentic turns
2389
+ // span thousands of events, so turn-granular keeping would barely move
2390
+ // the visible history). Fork mode (pi's /fork) instead KEEPS the
2391
+ // picked entry: a user message cuts right after itself (the turn's
2392
+ // reply drops) and never returns text to the input.
2393
+ const target = mode === 'fork'
2394
+ ? forkTarget(sourceEvents, seq)
2395
+ : rewindTarget(sourceEvents, seq);
2396
+ const boundary = target.boundary;
2397
+ if (boundary < 0) {
2398
+ state.notify(t('rewind-first-message'), { color: 'error' });
2399
+ return null;
2400
+ }
2401
+ // Keeping the entry can still be a NO-OP: when nothing message-bearing
2402
+ // follows the boundary (only a turn/end, or nothing at all), the fork's
2403
+ // transcript would be identical to the live one. pi truncates to right
2404
+ // after the entry; DSH's step/turn-closed seed cannot always express
2405
+ // that, so the honest answer is to say there is nothing to rewind. A
2406
+ // DEAD session's tip still forks: that adopts the branch, a real
2407
+ // switch.
2408
+ if (forkFromLive && !sourceEvents.some(event => event.seq > boundary &&
2409
+ (event.type === 'user/message' || event.type === 'assistant/message' ||
2410
+ event.type === 'tool/call' || event.type === 'tool/result'))) {
2411
+ state.notify(t('rewind-noop'), { color: 'warning' });
2412
+ return null;
2413
+ }
2414
+ // The dropped turn's own prompt text, restored into the input after
2415
+ // the swap ('' whenever the entry was kept — fork mode included — or
2416
+ // the turn had no human-typed text to restore).
2417
+ const restoredText = mode === 'fork' ? '' : turnUserText(sourceEvents, seq);
2418
+ // The fork continues under the source session's own preset: switches
2419
+ // are blank-only, so every `agent-preset/selected` event predates any
2420
+ // rewind boundary and the source log resolves the exact composition.
2421
+ // The route likewise stays the live one — a rewind continues the same
2422
+ // conversation, so a `/model` switch must survive it (issue #30).
2423
+ const sourcePreset = forkFromLive
2424
+ ? runningPresetOf(entrySession)
2425
+ : ((await resolvePersistedPreset(ctx, SessionId(sessionId))) ?? runningPresetOf(entrySession));
2426
+ const rewindComposed = await composePreset(ctx, sourcePreset);
2427
+ // Everything fallible is done — only NOW stop a running turn (a load
2428
+ // or preset failure above must not kill it). But bail first when the
2429
+ // live session was swapped during those awaits: cancelling/forking
2430
+ // now would hit the NEW session with THIS session's boundary.
2431
+ if (agent.session !== entrySession) {
2432
+ state.notify(t('rewind-session-changed'), { color: 'error' });
2433
+ return null;
2434
+ }
2435
+ // Stop a running turn first and WAIT for its turn/end to land: fork
2436
+ // rejects boundaries inside open turns, and Agent.cancel() closes the
2437
+ // turn asynchronously (a long thinking turn can take seconds to
2438
+ // settle). Cross-session rewinds need this too: the live agent is
2439
+ // about to be disposed, and its turn must close cleanly.
2440
+ const wasWorking = state.working;
2441
+ const cancelSeq = agent.session.seq;
2442
+ if (wasWorking)
2443
+ agent.cancel({ kind: 'user' });
2444
+ if (wasWorking) {
2445
+ const turnSettled = await waitForTurnEnd(agent.session, cancelSeq, 30000);
2446
+ if (!turnSettled) {
2447
+ state.notify(t('rewind-settling'), { color: 'error' });
2448
+ return null;
2449
+ }
2450
+ }
2451
+ // Slice the seed from the PINNED event snapshot. Never sessions.fork
2452
+ // here: fork() rejects a boundary inside an open turn, which is
2453
+ // exactly where a keep-style cut lands (closeTurn set) — close it
2454
+ // with the exact event a real user interrupt writes instead (the
2455
+ // persistence layer closes crash-orphaned turns the same way).
2456
+ // agents.create validates the result itself (contiguous from seq 0,
2457
+ // no open turns).
2458
+ const seed = sourceEvents.filter(event => event.seq <= boundary);
2459
+ if (target.closeTurn !== undefined) {
2460
+ const last = seed[seed.length - 1];
2461
+ if (last !== undefined) {
2462
+ seed.push({
2463
+ type: 'turn/end',
2464
+ seq: last.seq + 1,
2465
+ time: last.time + 1,
2466
+ data: { turn: target.closeTurn, reason: { kind: 'aborted', reason: { kind: 'user' } } },
2467
+ });
2468
+ }
2469
+ }
2470
+ let handle;
2471
+ try {
2472
+ handle = await agents.create({
2473
+ sessionId: childId,
2474
+ seed,
2475
+ meta: {
2476
+ cwd: sourceCwd,
2477
+ parentSession: SessionId(sessionId),
2478
+ seedLength: seed.length,
2479
+ ...(rewindComposed.agentPreset === undefined
2480
+ ? {}
2481
+ : { agentPreset: rewindComposed.agentPreset }),
2482
+ },
2483
+ agentOptions: { provider: state.provider, model: state.model },
2484
+ ...(rewindComposed.setup === undefined ? {} : { setup: rewindComposed.setup }),
2485
+ });
2486
+ }
2487
+ catch {
2488
+ state.notify(t('rewind-create-failed'), { color: 'error' });
2489
+ return null;
2490
+ }
2491
+ try {
2492
+ await attachSessionToWorkspace(ctx, sourceCwd, childId);
2493
+ }
2494
+ catch (error) {
2495
+ state.notify(t('rewind-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
2496
+ }
2497
+ // The create await was another swap window: adopting now would dispose
2498
+ // the NEW session's agent. Free the fork we just made and bail.
2499
+ if (agent.session !== entrySession) {
2500
+ void handle.dispose().catch(() => { });
2501
+ state.notify(t('rewind-session-changed'), { color: 'error' });
2502
+ return null;
2503
+ }
2504
+ // Replay the forked history into a fresh transcript (the same swap
2505
+ // tail rewindTo runs), then announce the session switch.
2506
+ const sourceSessionId = adoptForkedAgent(handle, seed, rewindComposed.agentPreset, childId);
2507
+ notifySessionSwitched(mode === 'fork' ? 'fork' : 'rewind', String(childId), sourceSessionId);
2508
+ return restoredText;
2509
+ },
2510
+ async forkSession() {
2511
+ const sessions = ctx.get('sessions');
2512
+ const agents = ctx.get('agents');
2513
+ if (!sessions || !agents) {
2514
+ state.notify(t('fork-unavailable'), { color: 'error' });
2515
+ return false;
2516
+ }
2517
+ // kimi-code /fork semantics: refuse mid-turn instead of cancelling —
2518
+ // the fork must not surprise the user by killing their running turn,
2519
+ // and sessions.fork rejects an open-turn log anyway.
2520
+ if (state.working) {
2521
+ state.notify(t('fork-while-working'), { color: 'warning' });
2522
+ return false;
2523
+ }
2524
+ const source = agent.session;
2525
+ const childId = SessionId(randomUUID());
2526
+ // No boundary: the whole (turn-closed) log. Slice via sessions.fork for
2527
+ // the same validation the rewind path gets, never sessions.fork's
2528
+ // session-storing sibling — agents.create must own the new session.
2529
+ let seed;
2530
+ try {
2531
+ seed = sessions.fork(source).events;
2532
+ }
2533
+ catch (error) {
2534
+ const message = error instanceof Error ? error.message : String(error);
2535
+ state.notify(t('fork-failed', { err: message }), { color: 'error' });
2536
+ return false;
2537
+ }
2538
+ // Same preset/route rule as a rewind fork: the source log's own
2539
+ // composition, the live route (a /model switch survives forking).
2540
+ const forkComposed = await composePreset(ctx, runningPresetOf(source));
2541
+ let handle;
2542
+ try {
2543
+ handle = await agents.create({
2544
+ sessionId: childId,
2545
+ seed,
2546
+ meta: {
2547
+ cwd: state.cwd,
2548
+ // NO parentSession: a /fork copy is an independent conversation
2549
+ // (kimi-code semantics — a copy of the message list under a new
2550
+ // root session, like /new plus the history), not a rewind branch.
2551
+ // Recording lineage would fold it into the source's family in
2552
+ // /resume and the user would never find it.
2553
+ seedLength: seed.length,
2554
+ ...(forkComposed.agentPreset === undefined
2555
+ ? {}
2556
+ : { agentPreset: forkComposed.agentPreset }),
2557
+ },
2558
+ agentOptions: { provider: state.provider, model: state.model },
2559
+ ...(forkComposed.setup === undefined ? {} : { setup: forkComposed.setup }),
2560
+ });
2561
+ }
2562
+ catch {
2563
+ state.notify(t('fork-create-failed'), { color: 'error' });
2564
+ return false;
2565
+ }
2566
+ // STAY in the source session: adopting the fork would dispose the live
2567
+ // agent (killing its in-flight turn and background tasks) — the fork is
2568
+ // an independent copy the user enters via /resume or the printed resume
2569
+ // command. The teardown order matters:
2570
+ // 1. attach while the fork's agent is still LIVE — the workspace's
2571
+ // header read resolves live sessions from the registry, so attaching
2572
+ // after dispose races the persistence index and can fail with
2573
+ // "cannot validate session".
2574
+ // 2. await the dispose so the seed log finishes flushing…
2575
+ // 3. …then append the Fork: title — appending mid-flush races the
2576
+ // writer and the frame is silently dropped.
2577
+ try {
2578
+ await attachSessionToWorkspace(ctx, state.cwd, childId);
2579
+ }
2580
+ catch (error) {
2581
+ state.notify(t('fork-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
2582
+ }
2583
+ try {
2584
+ await handle.dispose();
2585
+ }
2586
+ catch (error) {
2587
+ ctx.logger.warn('dsh-tui: forked session dispose failed: %o', error);
2588
+ }
2589
+ // kimi's naming convention: the fork wears `Fork: <source title>` (the
2590
+ // prefix stays English in both locales). Best effort — a backend whose
2591
+ // log the compat layer cannot reach just leaves the fork untitled.
2592
+ const sourceTitle = state.sessionTitle.trim();
2593
+ appendSessionTitle(String(childId), `Fork: ${sourceTitle === '' ? String(source.id).slice(0, 8) : sourceTitle}`);
2594
+ // The same resume-command shape the exit hint prints (plugin.ts
2595
+ // resumeCommand): DSH_TUI_RESUME_SESSION + the boot profile.
2596
+ const profile = resolveDshProfileName();
2597
+ const boot = profile === undefined ? 'dsh --config cordis.yml' : `dsh --profile ${profile}`;
2598
+ const command = process.platform === 'win32'
2599
+ ? `dsh-tui --resume ${childId}`
2600
+ : `DSH_TUI_RESUME_SESSION=${childId} ${boot}`;
2601
+ state.notify(t('fork-done', { id: String(childId), command }), { timeoutMs: 8000 });
2602
+ return true;
2603
+ },
1612
2604
  async resumeTo(sessionId) {
1613
2605
  // Switch the live agent to a persisted session: /resume picker Enter
1614
2606
  // loads the history immediately (the `--resume` launcher path keeps
@@ -1647,10 +2639,20 @@ export function createChannel(ctx, initialAgent, options) {
1647
2639
  provider: options.configuredProvider,
1648
2640
  model: options.configuredModel,
1649
2641
  });
2642
+ // The recorded route feeds back into agentOptions too — not just the
2643
+ // status line below: a provider-only cordis.yml pin (issue #67) leaves
2644
+ // agentOptions.model undefined on resume, which breaks the `{{model}}`
2645
+ // persona variable for the resumed agent's own assembly AND for every
2646
+ // subagent it spawns (dsh-subagent's resolveChildAgentOptions inherits
2647
+ // `parent.options.model`).
2648
+ const recordedRoute = await resolvePersistedRoute(ctx, SessionId(sessionId));
1650
2649
  try {
1651
2650
  handle = await agents.resume({
1652
2651
  resumeSessionId: SessionId(sessionId),
1653
- agentOptions: { provider: resumeRoute?.provider, model: resumeRoute?.model },
2652
+ agentOptions: {
2653
+ provider: resumeRoute?.provider ?? recordedRoute?.provider,
2654
+ model: resumeRoute?.model ?? recordedRoute?.model,
2655
+ },
1654
2656
  ...(resumeComposed.setup === undefined ? {} : { setup: resumeComposed.setup }),
1655
2657
  });
1656
2658
  }
@@ -1689,6 +2691,7 @@ export function createChannel(ctx, initialAgent, options) {
1689
2691
  state.pending = [];
1690
2692
  state.goal = undefined;
1691
2693
  state.sessionTitle = '';
2694
+ state.sessionColor = '';
1692
2695
  state.tokens = { input: 0, output: 0 };
1693
2696
  state.responseChars = 0;
1694
2697
  state.activeToolCount = 0;
@@ -1856,6 +2859,7 @@ export function createChannel(ctx, initialAgent, options) {
1856
2859
  state.pending = [];
1857
2860
  state.goal = undefined;
1858
2861
  state.sessionTitle = '';
2862
+ state.sessionColor = '';
1859
2863
  state.tokens = { input: 0, output: 0 };
1860
2864
  state.responseChars = 0;
1861
2865
  state.activeToolCount = 0;
@@ -2039,6 +3043,7 @@ export function createChannel(ctx, initialAgent, options) {
2039
3043
  state.pending = [];
2040
3044
  state.goal = undefined;
2041
3045
  state.sessionTitle = '';
3046
+ state.sessionColor = '';
2042
3047
  state.tokens = { input: 0, output: 0 };
2043
3048
  state.responseChars = 0;
2044
3049
  state.activeToolCount = 0;
@@ -2050,6 +3055,11 @@ export function createChannel(ctx, initialAgent, options) {
2050
3055
  state.agentPreset = modelComposed.agentPreset;
2051
3056
  state.model = model;
2052
3057
  state.provider = provider;
3058
+ // /model completion cache: the [current] tag was resolved at fetch
3059
+ // time — drop the cache so the next `/model ` refetches for the new
3060
+ // route.
3061
+ modelNodeCache.nodes = undefined;
3062
+ modelNodeCache.load = undefined;
2053
3063
  state.tps = undefined;
2054
3064
  state.tpsSamples = [];
2055
3065
  state.lastUsage = undefined;
@@ -2160,6 +3170,19 @@ export function createChannel(ctx, initialAgent, options) {
2160
3170
  state.toolBackground = normalized;
2161
3171
  state.emit();
2162
3172
  },
3173
+ setScrollGutter(mode) {
3174
+ const normalized = normalizeScrollGutter(mode);
3175
+ if (normalized === state.scrollGutter)
3176
+ return;
3177
+ state.scrollGutter = normalized;
3178
+ state.emit();
3179
+ },
3180
+ setPromptSessionLabel(enabled) {
3181
+ if (enabled === state.promptSessionLabel)
3182
+ return;
3183
+ state.promptSessionLabel = enabled;
3184
+ state.emit();
3185
+ },
2163
3186
  setStatusBar(config) {
2164
3187
  const next = normalizeStatusBar({ ...state.statusBar, ...config });
2165
3188
  const changed = Object.keys(next).some(key => next[key] !== state.statusBar[key]);
@@ -2290,6 +3313,12 @@ export function createChannel(ctx, initialAgent, options) {
2290
3313
  return Promise.all(providers.map(provider => llm.listModels(provider.id).catch(() => [])))
2291
3314
  .then(lists => lists.flat());
2292
3315
  },
3316
+ listProviders() {
3317
+ // Group labels for the two-level /model picker: the registry's own
3318
+ // display names, detached so a registry swap cannot leak through.
3319
+ const llm = ctx.get('llm');
3320
+ return Promise.resolve(llm === undefined ? [] : llm.listProviders().map(info => ({ ...info })));
3321
+ },
2293
3322
  async listSkills() {
2294
3323
  // snapshot() over list(): only a COMPLETE observation is authoritative
2295
3324
  // (same contract as the skill-command merge above) — a partial catalog
@@ -2386,7 +3415,12 @@ export function createChannel(ctx, initialAgent, options) {
2386
3415
  return undefined;
2387
3416
  }
2388
3417
  const revision = () => settings.describe().find(descriptor => descriptor.ns === 'llm-pi-ai')?.revision;
3418
+ // The OAuth sign-in surface (dsh-auth-style plugin), structural and
3419
+ // optional: mounting the plugin lights up the wizard's OAuth branch,
3420
+ // and without it the wizard is exactly what it was before.
3421
+ const oauthApi = ctx.get('dshAuth')?.api;
2389
3422
  return {
3423
+ ...(oauthApi === undefined ? {} : { oauth: oauthApi }),
2390
3424
  listCatalogProviders() {
2391
3425
  // declared === true marks routes the adapter knows only because a
2392
3426
  // stored profile names them (user-added); the rest are activatable
@@ -2432,6 +3466,12 @@ export function createChannel(ctx, initialAgent, options) {
2432
3466
  },
2433
3467
  };
2434
3468
  },
3469
+ async oauthProviderStatuses() {
3470
+ // Same optional seam the wizard's OAuth branch reads: absent plugin →
3471
+ // undefined, and `/login` renders exactly its pre-plugin lines.
3472
+ const api = ctx.get('dshAuth')?.api;
3473
+ return api === undefined ? undefined : api.providers();
3474
+ },
2435
3475
  async sideQuestion(question, options) {
2436
3476
  // CC /btw:无工具单轮辅助调用,重放 deriveMessages() 前缀 + 一条
2437
3477
  // 包装问题。tools 永不传(侧问无工具是核心语义);usage 不回收
@@ -2524,6 +3564,61 @@ export function createChannel(ctx, initialAgent, options) {
2524
3564
  state.sessionTitle = title;
2525
3565
  state.emit();
2526
3566
  },
3567
+ setSessionColor(color) {
3568
+ // `session/color` is a dsh-tui plugin event — not in dsh-session's
3569
+ // typed union, so appended through the same cast applyMode uses for
3570
+ // its sandbox/approval overrides. It replays on resume/rewind like
3571
+ // session/title, keeping each session's accent color its own.
3572
+ ;
3573
+ agent.session
3574
+ .append('session/color', { color });
3575
+ state.sessionColor = color;
3576
+ state.emit();
3577
+ },
3578
+ async recapRecent(options) {
3579
+ // `/recap` (pi-recap semantics): one tool-less LLM call over the
3580
+ // session's TAIL exchanges — unlike /btw it does not replay the full
3581
+ // derived history (the excerpt IS the payload), so it stays cheap.
3582
+ // The answer is pure UI state: never appended to the session log.
3583
+ const llm = ctx.get('llm');
3584
+ if (!llm)
3585
+ return { summary: null, error: t('recap-llm-unavailable') };
3586
+ const header = agent.session.requestHeader();
3587
+ const config = header?.config;
3588
+ const activity = collectRecentActivity(agent.session.events, RECAP_RECENT_CHARS);
3589
+ if (activity === '')
3590
+ return { summary: null, error: t('recap-no-activity') };
3591
+ const messages = [
3592
+ createUserMessage({
3593
+ content: [{ type: 'text', text: wrapRecapPrompt(activity) }],
3594
+ source: { kind: 'plugin', plugin: 'dsh-tui/recap' },
3595
+ }),
3596
+ ];
3597
+ const request = {
3598
+ provider: config?.provider ?? state.provider,
3599
+ model: config?.model ?? state.model,
3600
+ messages,
3601
+ ...(header?.system !== undefined && { system: header.system }),
3602
+ ...(config?.reasoningEffort !== undefined && { reasoningEffort: config.reasoningEffort }),
3603
+ ...(config?.temperature !== undefined && { temperature: config.temperature }),
3604
+ ...(config?.maxTokens !== undefined && { maxTokens: config.maxTokens }),
3605
+ ...(config?.stop !== undefined && { stop: [...config.stop] }),
3606
+ sessionId: agent.session.id,
3607
+ ...(options?.signal && { signal: options.signal }),
3608
+ };
3609
+ const outcome = await runSideQuestion({
3610
+ stream: llm.stream.bind(llm),
3611
+ options: request,
3612
+ onText: options?.onText,
3613
+ signal: options?.signal,
3614
+ });
3615
+ if (outcome.answer === null)
3616
+ return { summary: null, error: outcome.error };
3617
+ const parsed = parseRecapResponse(outcome.answer);
3618
+ return parsed.title === undefined
3619
+ ? { summary: parsed.summary }
3620
+ : { summary: parsed.summary, title: parsed.title };
3621
+ },
2527
3622
  async deleteSession(sessionId) {
2528
3623
  // The live session's log is still being appended by this process —
2529
3624
  // deleting it from under the writer is never offered in the picker
@@ -2619,7 +3714,13 @@ export function createChannel(ctx, initialAgent, options) {
2619
3714
  .catch((error) => {
2620
3715
  state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
2621
3716
  });
2622
- })();
3717
+ })().catch((error) => {
3718
+ // Sync throws from compactNow (e.g. runMaintenance rejecting a
3719
+ // non-idle agent right after /resume) reject this IIFE itself;
3720
+ // uncaught, that is an unhandled rejection and Node exits the
3721
+ // whole TUI. Surface it as the same failure notification.
3722
+ state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
3723
+ });
2623
3724
  },
2624
3725
  runExternalCommand(name, rawInput) {
2625
3726
  return executeRegistryCommand(name, rawInput);
@@ -3341,6 +4442,12 @@ ${output}
3341
4442
  * its result-time contextual diff back from here). */
3342
4443
  const presentResultView = (name, rawArgs, data) => {
3343
4444
  try {
4445
+ // Harness goal/todo tools first: their raw JSON reads as noise in the
4446
+ // transcript — fold recognizable shapes into a summary card before the
4447
+ // registry gets a chance to (not) know them.
4448
+ const local = harnessToolResultView(name, data);
4449
+ if (local !== undefined)
4450
+ return local;
3344
4451
  const tool = toolsRegistry?.get(name, agent);
3345
4452
  if (tool?.presentResult === undefined)
3346
4453
  return undefined;
@@ -3473,9 +4580,21 @@ ${output}
3473
4580
  state.spinnerMode = 'requesting';
3474
4581
  }
3475
4582
  };
4583
+ /** Fold one goal mutation into the channel's goal projection. */
4584
+ const applyGoalChange = (change) => {
4585
+ if (change.operation === 'clear') {
4586
+ state.goal = undefined;
4587
+ }
4588
+ else if (change.goal !== undefined) {
4589
+ state.goal = {
4590
+ ...change.goal,
4591
+ roundsStarted: change.roundsStarted ?? state.goal?.roundsStarted ?? 0,
4592
+ };
4593
+ }
4594
+ };
3476
4595
  /**
3477
4596
  * Fold one goal-sourced message into the channel's goal projection.
3478
- * Round-zero goal messages carry the full durable snapshot (or a clear
4597
+ * Round-zero goal messages may carry the full durable snapshot (or a clear
3479
4598
  * tombstone) in their source; positive-round messages are admitted
3480
4599
  * continuation prompts that only advance the rounds counter.
3481
4600
  */
@@ -3495,15 +4614,7 @@ ${output}
3495
4614
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may not match the static type
3496
4615
  if (change === undefined || change.kind !== 'goal/change')
3497
4616
  return;
3498
- if (change.operation === 'clear') {
3499
- state.goal = undefined;
3500
- }
3501
- else if (change.goal !== undefined) {
3502
- state.goal = {
3503
- ...change.goal,
3504
- roundsStarted: change.roundsStarted ?? state.goal?.roundsStarted ?? 0,
3505
- };
3506
- }
4617
+ applyGoalChange(change);
3507
4618
  };
3508
4619
  /** True while the durable transcript is being replayed (boot /resume /
3509
4620
  * rewind / model-switch fork). The assistant/message reasoning-rebuild
@@ -3531,6 +4642,15 @@ ${output}
3531
4642
  }
3532
4643
  };
3533
4644
  const renderEvent = (event) => {
4645
+ // Top-level `goal/change` events are how the goal service actually
4646
+ // records durable goal mutations (create/edit/pause/resume/complete/
4647
+ // block/clear) — confirmed in production logs. The pinned peer's
4648
+ // SessionEvent union predates the type, so admit it structurally: the
4649
+ // goal chip and panel stay dark without this fold.
4650
+ if (event.type === 'goal/change') {
4651
+ applyGoalChange(event.data);
4652
+ return;
4653
+ }
3534
4654
  switch (event.type) {
3535
4655
  case 'user/message': {
3536
4656
  // Compaction checkpoint: `source = { kind: 'plugin', plugin:
@@ -3573,10 +4693,13 @@ ${output}
3573
4693
  contextWarned = false;
3574
4694
  break;
3575
4695
  }
3576
- // Same-session goal domain: round-zero goal-sourced messages carry
3577
- // the durable goal snapshot (or clear tombstone) in their source.
3578
- // They are not transcript bubbles — they drive the goal panel's
3579
- // live projection (replayed on resume/rewind like every other event).
4696
+ // Same-session goal domain: goal-sourced messages are the round
4697
+ // driver's continuation prompts (positive rounds advance the
4698
+ // counter); some hosts also inline the durable snapshot in a
4699
+ // round-zero source. They are not transcript bubbles — they drive
4700
+ // the goal panel's live projection (replayed on resume/rewind like
4701
+ // every other event; the snapshot itself arrives as the top-level
4702
+ // `goal/change` event admitted above).
3580
4703
  if (event.data.source.kind === 'goal') {
3581
4704
  applyGoalEvent(event);
3582
4705
  break;
@@ -3976,6 +5099,13 @@ ${output}
3976
5099
  nextRowId += 1;
3977
5100
  break;
3978
5101
  }
5102
+ // `/color` accent (dsh-tui plugin event, replayed on resume/rewind
5103
+ // like session/title): last write wins, '' clears to the default.
5104
+ if (event.type === 'session/color') {
5105
+ const data = event.data;
5106
+ state.sessionColor = typeof data.color === 'string' ? data.color : '';
5107
+ break;
5108
+ }
3979
5109
  // Custom plugin events (tuiRenderers seam): a registered renderer
3980
5110
  // maps the payload to text rows — title as a local row, body as
3981
5111
  // preview-clipped local-output rows, same shape pushLocal uses.
@@ -4158,12 +5288,18 @@ ${output}
4158
5288
  const subagentId = subagentStore.getSubagentIdBySession(session);
4159
5289
  if (subagentId) {
4160
5290
  subagentStore.onSessionEvent(subagentId, event);
4161
- state.subagents = subagentStore.snapshot();
4162
- syncSubagentRows();
4163
- if (event.type === 'assistant/chunk')
5291
+ if (event.type === 'assistant/chunk') {
5292
+ // Token-rate path (100-300 events/s): the store append stays
5293
+ // synchronous (cheap); the expensive snapshot + row projection
5294
+ // defers to the frame-aligned flush inside emitStream's 16ms
5295
+ // timer, so it coalesces exactly like the main-agent stream.
5296
+ subagentStreamDirty = true;
4164
5297
  state.emitStream();
4165
- else
5298
+ }
5299
+ else {
5300
+ syncSubagentsNow();
4166
5301
  state.emit();
5302
+ }
4167
5303
  return;
4168
5304
  }
4169
5305
  // Otherwise handle main agent session
@@ -4226,8 +5362,7 @@ ${output}
4226
5362
  catch {
4227
5363
  // Session discovery is best-effort and must not break the parent turn.
4228
5364
  }
4229
- state.subagents = subagentStore.snapshot();
4230
- syncSubagentRows();
5365
+ syncSubagentsNow();
4231
5366
  state.emit();
4232
5367
  });
4233
5368
  const disposeEnd = ctx.on('subagent/end', (info) => {
@@ -4249,8 +5384,7 @@ ${output}
4249
5384
  subagentStore.onCancelled(info.id, info.stopReason, output);
4250
5385
  else
4251
5386
  subagentStore.onFailed(info.id, info.stopReason || 'Unknown error');
4252
- state.subagents = subagentStore.snapshot();
4253
- syncSubagentRows();
5387
+ syncSubagentsNow();
4254
5388
  state.emit();
4255
5389
  });
4256
5390
  return () => {
@@ -4519,6 +5653,20 @@ const MENTION_IMAGE_MEDIA_TYPES = {
4519
5653
  function mentionImageMediaType(path) {
4520
5654
  return MENTION_IMAGE_MEDIA_TYPES[extname(path).toLowerCase()];
4521
5655
  }
5656
+ /** Resolve+stat one candidate path; undefined when it throws OR stats
5657
+ * absent — both are a miss for strip-first mention resolution (issue #359). */
5658
+ async function tryResolveMention(fs, absolute) {
5659
+ try {
5660
+ const target = await fs.resolve(absolute);
5661
+ const info = await fs.stat(target);
5662
+ if (info === undefined)
5663
+ return undefined;
5664
+ return { target, info };
5665
+ }
5666
+ catch {
5667
+ return undefined;
5668
+ }
5669
+ }
4522
5670
  /**
4523
5671
  * Expand a submitted text's `@` mentions (issue #15) into model-facing
4524
5672
  * attachment blocks: supported image files become durable image blocks,
@@ -4537,68 +5685,99 @@ export async function expandMentions(fs, cwd, text, attachments, stagedImages) {
4537
5685
  let imageBytes = 0;
4538
5686
  if (fs !== undefined) {
4539
5687
  for (const mention of mentions) {
5688
+ const display = mention.literal ?? mention.path;
4540
5689
  const imageMediaType = mentionImageMediaType(mention.path);
4541
5690
  if (budget <= 0 && imageMediaType === undefined)
4542
5691
  break;
4543
5692
  // Mentions resolve against the session cwd, same as the model-facing fs
4544
- // tools; absolute paths pass through untouched.
5693
+ // tools; absolute paths pass through untouched. A `#L12-14` line suffix
5694
+ // (issue #359) is stripped before resolution; when the stripped path
5695
+ // misses, the typed literal (suffix intact) gets ONE fallback try so
5696
+ // filenames genuinely containing `#L…` still resolve as whole files.
4545
5697
  const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
4546
- let target;
4547
- let info;
4548
- try {
4549
- target = await fs.resolve(absolute);
4550
- info = await fs.stat(target);
4551
- }
4552
- catch {
4553
- missing.push(mention.path);
5698
+ let resolved = await tryResolveMention(fs, absolute);
5699
+ let literalFallback = false;
5700
+ if (resolved === undefined && mention.literal !== undefined) {
5701
+ const literalPath = isAbsolute(mention.literal) ? mention.literal : join(cwd, mention.literal);
5702
+ resolved = await tryResolveMention(fs, literalPath);
5703
+ literalFallback = resolved !== undefined;
5704
+ }
5705
+ if (resolved === undefined) {
5706
+ missing.push(display);
4554
5707
  continue;
4555
5708
  }
5709
+ const { target, info } = resolved;
5710
+ // On a literal-fallback hit the attached file IS the typed name — the
5711
+ // model must see that path, not the suffix-stripped one.
5712
+ const shownPath = literalFallback ? display : mention.path;
5713
+ // …and judge image-ness by the typed extension in that case too.
5714
+ const imageType = literalFallback && mention.literal !== undefined
5715
+ ? mentionImageMediaType(mention.literal)
5716
+ : imageMediaType;
4556
5717
  if (info?.type === 'file') {
4557
- if (imageMediaType !== undefined && attachments !== undefined && fs.readBytes !== undefined) {
5718
+ if (imageType !== undefined && attachments !== undefined && fs.readBytes !== undefined) {
4558
5719
  const limits = attachments.imageLimits;
4559
- if (!limits.mediaTypes.includes(imageMediaType) || imageCount >= limits.maxImagesPerMessage) {
4560
- missing.push(mention.path);
5720
+ if (!limits.mediaTypes.includes(imageType) || imageCount >= limits.maxImagesPerMessage) {
5721
+ missing.push(display);
4561
5722
  continue;
4562
5723
  }
4563
5724
  try {
4564
5725
  const data = await fs.readBytes(target, undefined, limits.maxImageBytes);
4565
5726
  if (imageBytes + data.byteLength > limits.maxMessageImageBytes) {
4566
- missing.push(mention.path);
5727
+ missing.push(display);
4567
5728
  continue;
4568
5729
  }
4569
5730
  const attachment = await attachments.saveImage({
4570
5731
  data,
4571
- mediaType: imageMediaType,
5732
+ mediaType: imageType,
4572
5733
  name: basename(target.displayPath),
4573
5734
  });
4574
5735
  blocks.push({ type: 'image', attachment });
4575
5736
  imageCount += 1;
4576
5737
  imageBytes += data.byteLength;
4577
- attached.push(mention.path);
5738
+ attached.push(display);
4578
5739
  }
4579
5740
  catch {
4580
- missing.push(mention.path);
5741
+ missing.push(display);
4581
5742
  }
4582
5743
  continue;
4583
5744
  }
4584
5745
  try {
4585
5746
  const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
4586
- let content = await fs.readText(target);
5747
+ const content = await fs.readText(target);
5748
+ let body = content;
4587
5749
  let truncated = false;
4588
- if (content.length > cap) {
4589
- content = content.slice(0, cap);
5750
+ let header = `<attached-file path="${shownPath}">`;
5751
+ if (mention.startLine !== undefined && !literalFallback) {
5752
+ // Line-range slice (issue #359): 1-based inclusive. An endLine
5753
+ // past EOF clamps to the file; a startLine past EOF falls back
5754
+ // to the whole file with an in-band note — never a silent
5755
+ // empty attach. Line ranges never apply to literal-fallback
5756
+ // hits (those files really are named `…#L…`, no suffix typed).
5757
+ const lines = content.split('\n');
5758
+ if (mention.startLine > lines.length) {
5759
+ header = `<attached-file path="${shownPath}" lines="${mention.startLine}-${mention.endLine}" note="requested lines beyond EOF (file has ${lines.length} line${lines.length === 1 ? '' : 's'}); whole file attached">`;
5760
+ }
5761
+ else {
5762
+ const endLine = Math.min(mention.endLine ?? mention.startLine, lines.length);
5763
+ header = `<attached-file path="${shownPath}" lines="${mention.startLine}${endLine === mention.startLine ? '' : `-${endLine}`}">`;
5764
+ body = lines.slice(mention.startLine - 1, endLine).join('\n');
5765
+ }
5766
+ }
5767
+ if (body.length > cap) {
5768
+ body = body.slice(0, cap);
4590
5769
  truncated = true;
4591
5770
  }
4592
- budget -= content.length;
5771
+ budget -= body.length;
4593
5772
  blocks.push({
4594
5773
  type: 'text',
4595
- text: `<attached-file path="${mention.path}">\n${content}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
5774
+ text: `${header}\n${body}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
4596
5775
  });
4597
- attached.push(mention.path);
5776
+ attached.push(display);
4598
5777
  }
4599
5778
  catch {
4600
5779
  // Binary/undecodable or unreadable — report it like a miss.
4601
- missing.push(mention.path);
5780
+ missing.push(display);
4602
5781
  }
4603
5782
  continue;
4604
5783
  }
@@ -4615,17 +5794,17 @@ export async function expandMentions(fs, cwd, text, attachments, stagedImages) {
4615
5794
  budget -= body.length;
4616
5795
  blocks.push({
4617
5796
  type: 'text',
4618
- text: `<attached-directory path="${mention.path}">\n${body}\n</attached-directory>`,
5797
+ text: `<attached-directory path="${shownPath}">\n${body}\n</attached-directory>`,
4619
5798
  });
4620
- attached.push(mention.path);
5799
+ attached.push(display);
4621
5800
  }
4622
5801
  catch {
4623
- missing.push(mention.path);
5802
+ missing.push(display);
4624
5803
  }
4625
5804
  continue;
4626
5805
  }
4627
5806
  // Absent (stat → undefined) or a special file.
4628
- missing.push(mention.path);
5807
+ missing.push(display);
4629
5808
  }
4630
5809
  }
4631
5810
  if (attachments !== undefined && stagedImages !== undefined) {