@chatcode/cco-better-sidebar 0.18.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
package/src/index.ts ADDED
@@ -0,0 +1,1524 @@
1
+ /**
2
+ * dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
3
+ * read/write, git), the /sidebar/file media route (images), the /sidebar/html
4
+ * preview route, the /sidebar/bundle lazy-chunk route (client code splits),
5
+ * and the terminal WebSocket upgrade. Every route passes the same
6
+ * browser-trust fence as the /api gateway — Host-header loopback or the
7
+ * web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
8
+ * `--trusted-host` authorities), read per request from the live service
9
+ * value so the fence tracks the same trust source the /api gateway derives
10
+ * its list from.
11
+ *
12
+ * All operations are conversation-scoped: requests carry a sessionId, the
13
+ * session's authoritative cwd comes from the session store, and terminal
14
+ * processes are keyed by session.
15
+ */
16
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
17
+ import { basename, dirname, extname, isAbsolute, join } from 'node:path'
18
+ import type { IncomingMessage } from 'node:http'
19
+ import type { Duplex } from 'node:stream'
20
+ import { WebSocket, WebSocketServer } from 'ws'
21
+ import type { Context, SidebarHttpRequest } from './context-types.ts'
22
+ import {
23
+ Config,
24
+ PrefsSchema,
25
+ resolveSidebarConfig,
26
+ SIDEBAR_PREFS_DEFAULTS,
27
+ SIDEBAR_PREFS_NS,
28
+ type ResolvedSidebarConfig,
29
+ type SidebarConfig,
30
+ type SidebarPrefs,
31
+ } from './config.ts'
32
+ import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
33
+ import { writeWorkspaceUpload } from './fs-operations.ts'
34
+ import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
35
+ import { searchFiles } from './fs-search.ts'
36
+ import { decodeHtmlUrl } from './html-route.ts'
37
+ import { extractFrameAncestors } from './browser-probe.ts'
38
+ import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
39
+ import { registerBundleRoute } from './bundle-route.ts'
40
+ import { registerLoginRoute } from './login-route.ts'
41
+ import { launchExternal } from './open-external.ts'
42
+ import * as git from './git.ts'
43
+ import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
44
+ import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
45
+ import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
46
+ import {
47
+ DSH_NODE_PTY_RANGE,
48
+ depsStatus,
49
+ loadNodePty,
50
+ PTY_DEPS_MISSING,
51
+ } from './pty-deps.ts'
52
+ import { registerTools } from './tools.ts'
53
+ import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
54
+ import { CodingPlanCatalogue } from './codingplan.ts'
55
+ import { checkOneUpdate, CHATCODE_UPDATE_PACKAGES, performAllUpdates } from './chatcode-update.ts'
56
+ import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
57
+ import { apply as applyUiTweaksHost } from './ui-tweaks/index.ts'
58
+
59
+ export { Config }
60
+ export type { SidebarConfig, ResolvedSidebarConfig }
61
+ // Re-export the Context augmentation (`declare module '@deepseek-ai/cordis'`)
62
+ // so consumers `import type {} from 'dsh-better-sidebar'` and gain
63
+ // `ctx.betterSidebar`; the Context re-export below is the vendored cordis
64
+ // Context intersected with the structural service faces.
65
+ // Also re-export the service descriptor types so consumers can type their
66
+ // registerTab / registerFileViewer arguments without reaching into /client.
67
+ export type { Context } from './context-types.ts'
68
+ export type {
69
+ BetterSidebarService,
70
+ TabDescriptor,
71
+ TabComponentProps,
72
+ FileViewerDescriptor,
73
+ FileViewerProps,
74
+ FileFetchStrategy,
75
+ } from './client/service.ts'
76
+
77
+ /** Plugin identity for cordis.yml rows. */
78
+ export const name = 'dsh-better-sidebar'
79
+
80
+ /** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */
81
+ export const inject = ['webServer', 'sessions', 'webRuntime', 'tools', 'settings']
82
+
83
+ /** Content types for the media route, by extension. */
84
+ const MEDIA_TYPES: Record<string, string> = {
85
+ '.png': 'image/png',
86
+ '.jpg': 'image/jpeg',
87
+ '.jpeg': 'image/jpeg',
88
+ '.gif': 'image/gif',
89
+ '.webp': 'image/webp',
90
+ '.svg': 'image/svg+xml',
91
+ '.bmp': 'image/bmp',
92
+ '.ico': 'image/x-icon',
93
+ '.avif': 'image/avif',
94
+ '.pdf': 'application/pdf',
95
+ '.html': 'text/html',
96
+ '.htm': 'text/html',
97
+ }
98
+
99
+ /** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
100
+ export function mediaTypeForPath(path: string): string {
101
+ return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
102
+ }
103
+
104
+ /**
105
+ * Resolve a session's authoritative working directory. The attached session
106
+ * header wins; while the session is still hydrating from persistence (the
107
+ * web client attaches the current conversation a moment after page load, so
108
+ * the very first sidebar requests can arrive detached) the caller's own
109
+ * list-summary cwd is used; the session-persistence index is queried as a
110
+ * last resort for cold (not-yet-attached) sessions so a detached first
111
+ * request still resolves the correct project instead of the host process
112
+ * cwd (which on Windows is the DSH source root after `dsh.cmd`'s `pushd`,
113
+ * causing every user-project path to be misclassified as "outside
114
+ * workspace"). The host process cwd is the FINAL fallback for deployments
115
+ * without persistence (tests / stripped-down hosts); production always
116
+ * provides persistence, so the bug-fix path (header → client → persistence)
117
+ * always resolves the real session cwd before reaching it.
118
+ */
119
+ async function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): Promise<string> {
120
+ const session = ctx.sessions.get(sessionId)
121
+ const headerCwd = session?.header.cwd
122
+ if (headerCwd !== undefined && headerCwd !== '') return headerCwd
123
+ if (clientCwd !== undefined && clientCwd !== '') {
124
+ try {
125
+ return requireAbsolute(clientCwd)
126
+ } catch {
127
+ throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
128
+ }
129
+ }
130
+ const persistence = ctx.get('sessionPersistence')
131
+ if (persistence !== undefined) {
132
+ const inspected = await persistence.inspect(sessionId)
133
+ const metaCwd = inspected.meta.cwd
134
+ if (metaCwd !== undefined && metaCwd !== '') {
135
+ try {
136
+ return requireAbsolute(metaCwd)
137
+ } catch {
138
+ throw new SidebarError('bad-request', `invalid working directory "${metaCwd}"`)
139
+ }
140
+ }
141
+ }
142
+ return process.cwd()
143
+ }
144
+
145
+ /** Optional repository selected by the Git panel when cwd is a container. */
146
+ function selectedRepoOf(payload: unknown): string | undefined {
147
+ const record = payload as { repoRoot?: unknown }
148
+ if (record.repoRoot === undefined) return undefined
149
+ return requireAbsolute(requireString(payload, 'repoRoot'))
150
+ }
151
+
152
+ /**
153
+ * Resolve a path that a git command reported — `git status`/`git diff`
154
+ * print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
155
+ * session cwd (a session inside a subdirectory of a repository). Absolute
156
+ * paths pass through; relative ones join the repo root (falling back to the
157
+ * cwd when the root cannot be resolved, e.g. a bare directory).
158
+ */
159
+ async function resolveGitPath(cwd: string, raw: string, selected?: string): Promise<string> {
160
+ if (isAbsolute(raw)) return requireAbsolute(raw)
161
+ // Prefer the session-relative interpretation when it names an existing
162
+ // path. Git status reports repository-root-relative names, but the sidebar
163
+ // security boundary is the session workspace; this preference keeps files
164
+ // inside a nested session readable without reopening the repository root.
165
+ const sessionPath = requireAbsolute(join(cwd, raw))
166
+ if (await stat(sessionPath).then(() => true).catch(() => false)) return sessionPath
167
+ const root = await git.repoRoot(cwd, selected).catch(() => cwd)
168
+ return requireAbsolute(join(root, raw))
169
+ }
170
+
171
+ /** How many leading bytes a binary read returns for client-side detect sniffing. */
172
+ const READ_HEAD_LIMIT = 4096
173
+
174
+ /** Text read of a file with the size cap; binary detection via NUL probe.
175
+ * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
176
+ * so the client can re-match viewers by content (`detect`). */
177
+ async function readText(path: string, readLimit: number): Promise<{
178
+ content: string
179
+ truncated: boolean
180
+ binary: boolean
181
+ size: number
182
+ head?: string
183
+ }> {
184
+ const info = await stat(path).catch((error: unknown) => {
185
+ throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
186
+ })
187
+ if (info.isDirectory()) {
188
+ throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
189
+ }
190
+ const size = info.size
191
+ const truncated = size > readLimit
192
+ const handle = await open(path, 'r').catch((error: unknown) => {
193
+ throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
194
+ })
195
+ try {
196
+ const buffer = Buffer.alloc(Math.min(size, readLimit))
197
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
198
+ const slice = buffer.subarray(0, bytesRead)
199
+ const binary = slice.includes(0)
200
+ const head = binary
201
+ ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
202
+ : undefined
203
+ return {
204
+ content: binary ? '' : slice.toString('utf8'),
205
+ truncated,
206
+ binary,
207
+ size,
208
+ head,
209
+ }
210
+ } finally {
211
+ await handle.close()
212
+ }
213
+ }
214
+
215
+ /** One API method dispatch table entry. */
216
+ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
217
+
218
+ /** Browser-safe face supplied by the optional ChatCode authentication bundle. */
219
+ interface ChatCodeAuthService {
220
+ status(): Promise<{
221
+ required: boolean
222
+ validation: 'valid' | 'invalid' | 'unavailable' | 'none'
223
+ login?: { sessionId: string; state: 'pending' | 'succeeded' | 'failed' | 'timed-out' | 'cancelled'; expiresAtMs: number }
224
+ configured: boolean
225
+ expiresAtMs?: number
226
+ userName?: string
227
+ emailAddress?: string
228
+ expired: boolean
229
+ }>
230
+ startLogin(): Promise<{ sessionId: string; url: string }>
231
+ cancelLogin(sessionId: string): Promise<void>
232
+ logout(): Promise<void>
233
+ /** Short-lived access token for server-side platform calls. Never crosses
234
+ * to the browser — the stats routes below proxy through the host. */
235
+ accessToken(signal?: AbortSignal): Promise<string | undefined>
236
+ }
237
+
238
+ /** Origin of the ChatCode personal-stats APIs (fixed; only day-range body
239
+ * fields vary, so the proxy can never be aimed elsewhere). */
240
+ const CHATCODE_STATS_ORIGIN = 'https://chatcode.chinaunicom.cn/caassist-api-lt/caassist/api'
241
+ const CHATCODE_RELEASE_NOTE_URL = 'https://chatcode.chinaunicom.cn/chatcode/api/v1/cli/release-note/current'
242
+
243
+ interface ReleaseNoteResult {
244
+ registered: boolean
245
+ release: null | {
246
+ id: number
247
+ packageName: string
248
+ versionNum: string
249
+ title?: string
250
+ contentMd: string
251
+ updatedAt?: string
252
+ }
253
+ }
254
+
255
+ /** Fetch one exact package/version release note. Historical versions are never requested or merged. */
256
+ async function chatcodeReleaseNote(packageName: string, versionNum: string): Promise<ReleaseNoteResult> {
257
+ const url = new URL(CHATCODE_RELEASE_NOTE_URL)
258
+ url.searchParams.set('packageName', packageName)
259
+ url.searchParams.set('versionNum', versionNum)
260
+ let response: Response
261
+ try {
262
+ response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
263
+ } catch {
264
+ throw new SidebarError('release-note-unreachable', 'the release-note API is unreachable', 502)
265
+ }
266
+ if (!response.ok) {
267
+ throw new SidebarError('release-note-http', `the release-note API returned HTTP ${response.status}`, 502)
268
+ }
269
+ const parsed: unknown = await response.json().catch(() => null)
270
+ const envelope = parsed !== null && typeof parsed === 'object' ? parsed as Record<string, unknown> : null
271
+ if (envelope === null || (envelope.code !== 200 && envelope.code !== '200')) {
272
+ throw new SidebarError('release-note-rejected', 'the release-note API rejected the request', 502)
273
+ }
274
+ const data = envelope.data !== null && typeof envelope.data === 'object'
275
+ ? envelope.data as Record<string, unknown>
276
+ : null
277
+ if (data === null || typeof data.registered !== 'boolean') {
278
+ throw new SidebarError('release-note-rejected', 'the release-note API returned an invalid response', 502)
279
+ }
280
+ const rawRelease = data.release !== null && typeof data.release === 'object'
281
+ ? data.release as Record<string, unknown>
282
+ : null
283
+ if (rawRelease === null) return { registered: data.registered, release: null }
284
+ if (rawRelease.packageName !== packageName || rawRelease.versionNum !== versionNum
285
+ || typeof rawRelease.contentMd !== 'string' || rawRelease.contentMd.length > 65_535) {
286
+ throw new SidebarError('release-note-rejected', 'the release-note API returned an invalid release', 502)
287
+ }
288
+ return {
289
+ registered: data.registered,
290
+ release: {
291
+ id: typeof rawRelease.id === 'number' ? rawRelease.id : 0,
292
+ packageName,
293
+ versionNum,
294
+ title: typeof rawRelease.title === 'string' ? rawRelease.title.slice(0, 200) : undefined,
295
+ contentMd: rawRelease.contentMd,
296
+ updatedAt: typeof rawRelease.updatedAt === 'string' ? rawRelease.updatedAt : undefined,
297
+ },
298
+ }
299
+ }
300
+
301
+ /** Business-success evidence of one ChatCode stats envelope (the platform
302
+ * answers either code "00000000" or success:true; older routes answer code
303
+ * "200"). */
304
+ function chatcodeStatsOk(envelope: Record<string, unknown>): boolean {
305
+ if (envelope.code === '00000000' || envelope.code === '200') return true
306
+ if (envelope.success === true) return true
307
+ return envelope.code === undefined && envelope.success === undefined
308
+ }
309
+
310
+ /**
311
+ * POST one ChatCode stats endpoint with the host-held access token and
312
+ * surface its data array. Runs server-side only: the token stays in this
313
+ * process and no failure message carries it (fetch errors never include
314
+ * request headers, and platform `message` fields are business text).
315
+ */
316
+ async function chatcodeStatsData(
317
+ service: ChatCodeAuthService,
318
+ path: string,
319
+ body: Record<string, string>,
320
+ ): Promise<unknown[]> {
321
+ const token = await service.accessToken()
322
+ if (token === undefined) {
323
+ throw new SidebarError('chatcode-unauthorized', 'sign in through the ChatCode account menu first', 401)
324
+ }
325
+ let response: Response
326
+ try {
327
+ response = await fetch(`${CHATCODE_STATS_ORIGIN}${path}`, {
328
+ method: 'POST',
329
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
330
+ body: JSON.stringify(body),
331
+ signal: AbortSignal.timeout(15_000),
332
+ })
333
+ } catch {
334
+ throw new SidebarError('chatcode-stats-unreachable', 'the ChatCode statistics API is unreachable', 502)
335
+ }
336
+ if (response.status === 401 || response.status === 403) {
337
+ throw new SidebarError('chatcode-unauthorized', 'the ChatCode session expired; sign in again from the account menu', 401)
338
+ }
339
+ if (!response.ok) {
340
+ throw new SidebarError('chatcode-stats-http', `the ChatCode statistics API returned HTTP ${response.status}`, 502)
341
+ }
342
+ const parsed: unknown = await response.json().catch(() => null)
343
+ const envelope = parsed !== null && typeof parsed === 'object' ? parsed as Record<string, unknown> : null
344
+ if (envelope === null || !chatcodeStatsOk(envelope)) {
345
+ const message = typeof envelope?.message === 'string' && envelope.message !== ''
346
+ ? envelope.message
347
+ : 'the ChatCode statistics API rejected the request'
348
+ throw new SidebarError('chatcode-stats-rejected', message, 502)
349
+ }
350
+ return Array.isArray(envelope.data) ? envelope.data : []
351
+ }
352
+
353
+ /**
354
+ * Parse a client-supplied day range (`YYYY-MM-DD`, begin ≤ end, span capped
355
+ * so one request can never ask the platform for an unbounded history).
356
+ */
357
+ function chatcodeDayRangeOf(payload: unknown): { begin: string; end: string } {
358
+ const begin = requireString(payload, 'begin')
359
+ const end = requireString(payload, 'end')
360
+ const DAY = /^\d{4}-\d{2}-\d{2}$/
361
+ if (!DAY.test(begin) || !DAY.test(end)) {
362
+ throw new SidebarError('bad-request', '"begin"/"end" must be YYYY-MM-DD')
363
+ }
364
+ const from = Date.parse(`${begin}T00:00:00Z`)
365
+ const to = Date.parse(`${end}T23:59:59Z`)
366
+ if (Number.isNaN(from) || Number.isNaN(to) || from > to) {
367
+ throw new SidebarError('bad-request', '"begin" must not be after "end"')
368
+ }
369
+ if (to - from > 370 * 86_400_000) {
370
+ throw new SidebarError('bad-request', 'the requested date range is too wide')
371
+ }
372
+ return { begin, end }
373
+ }
374
+
375
+ /**
376
+ * The live face of the side card settings namespace, bound to the settings
377
+ * service when it is mounted. The DSH settings RPC domain only serves
378
+ * allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
379
+ * and writes THIS namespace through the plugin's own fenced /sidebar routes,
380
+ * which call the seam in-process — no configuration-client gate involved.
381
+ */
382
+ export interface SidebarSettingsFace {
383
+ /** The current resolved value + revision (undefined while the settings service is absent). */
384
+ get(): { value?: unknown; revision?: number }
385
+ /**
386
+ * Whether the dsh-web-ui family's aionui-panel has been selected as the
387
+ * right-panel provider (the `aionui-panel` settings namespace resolves
388
+ * `rightPanel: 'aionui-panel'`). While true the sidebar must not mount —
389
+ * the two right panels are mutually exclusive. False when the namespace is
390
+ * absent (no aionui installed) or the provider is anything else.
391
+ */
392
+ externalDisable(): boolean
393
+ /** Merge a patch (revision-guarded) and return the fresh resolved view. */
394
+ update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
395
+ }
396
+
397
+ /** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
398
+ /**
399
+ * Resolve the settings-page terminal shell overrides (the terminal card's
400
+ * gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
401
+ * `shellArgs` (or the platform auto-resolution). The settings page is the
402
+ * runtime complement to the boot-time yaml — same contract, later binding:
403
+ * the values here win for terminals opened afterwards.
404
+ */
405
+ function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
406
+ const settings = getSettings()
407
+ const value = settings?.get().value
408
+ if (value === null || typeof value !== 'object') return {}
409
+ const record = value as Record<string, unknown>
410
+ const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
411
+ const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
412
+ return {
413
+ shell: shell === '' ? undefined : shell,
414
+ shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Whether the workspace fence is armed for the sidebar's filesystem routes
420
+ * (the settings-page `workspaceFence` switch under the files card's gear).
421
+ * An absent settings service or a missing field keeps the fence ON — the
422
+ * containment default never depends on the settings surface being reachable.
423
+ */
424
+ function fenceEnabledOf(getSettings: () => SidebarSettingsFace | undefined): boolean {
425
+ const settings = getSettings()
426
+ const value = settings?.get().value
427
+ if (value === null || typeof value !== 'object') return true
428
+ return (value as Record<string, unknown>).workspaceFence !== false
429
+ }
430
+
431
+ /**
432
+ * Parse the browser tab's `browserAllowedLoopback` allowlist into a matcher
433
+ * over host:port (same contract as the client-side helper in
434
+ * src/client/browser.ts — kept in sync). Bare hosts (`localhost`,
435
+ * `127.0.0.1`) match every port; `host:port` entries match exactly.
436
+ */
437
+ function parseLoopbackAllowlist(allowlist: string): (host: string, port: string) => boolean {
438
+ const entries = allowlist.split(',').map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '')
439
+ const exact = new Set(entries)
440
+ const hosts = new Set<string>()
441
+ for (const entry of entries) {
442
+ if (!entry.includes(':')) hosts.add(entry.replace(/^\[|\]$/g, ''))
443
+ }
444
+ return (host, port) => {
445
+ const key = `${host}:${port}`
446
+ if (exact.has(key) || exact.has(host)) return true
447
+ return port !== '' && hosts.has(host)
448
+ }
449
+ }
450
+
451
+ function buildApi(
452
+ ctx: Context,
453
+ ptyManager: PtyManager | null,
454
+ agentPtyRegistry: AgentPtyRegistry | null,
455
+ resolved: ResolvedSidebarConfig,
456
+ terminalShell: string,
457
+ getSettings: () => SidebarSettingsFace | undefined,
458
+ userName: string,
459
+ codingPlanCatalogue: CodingPlanCatalogue,
460
+ ): Record<string, ApiMethod> {
461
+ const chatcodeAuth = (): ChatCodeAuthService | undefined => ctx.get('chatcodeAuth') as ChatCodeAuthService | undefined
462
+ const requireChatcodeAuth = (): ChatCodeAuthService => {
463
+ const service = chatcodeAuth()
464
+ if (service === undefined) {
465
+ throw new SidebarError('chatcode-unavailable', 'the ChatCode authentication plugin is not mounted', 503)
466
+ }
467
+ return service
468
+ }
469
+ const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
470
+ const sessionId = requireString(payload, 'sessionId')
471
+ const record = payload as { cwd?: unknown } | null
472
+ const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
473
+ return { sessionId, cwd: await sessionCwdOf(ctx, sessionId, clientCwd) }
474
+ }
475
+ /** Resolve the optional Git-panel checkout selector against the authoritative
476
+ * session repository. Unlike `cwd`, `worktree` is never trusted directly. */
477
+ const gitCwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
478
+ const base = await cwdOf(payload)
479
+ const record = payload as { worktree?: unknown } | null
480
+ const requested = typeof record?.worktree === 'string' && record.worktree !== '' ? record.worktree : undefined
481
+ return { sessionId: base.sessionId, cwd: await git.resolveWorktree(base.cwd, requested) }
482
+ }
483
+ /** Server-side re-check of both ChatCode packages (the client never supplies
484
+ * versions to install). */
485
+ const checkAllChatcodeUpdates = async (): Promise<Awaited<ReturnType<typeof checkOneUpdate>>[]> => {
486
+ const packages: Awaited<ReturnType<typeof checkOneUpdate>>[] = []
487
+ for (const pkg of CHATCODE_UPDATE_PACKAGES) packages.push(await checkOneUpdate(pkg))
488
+ return packages
489
+ }
490
+ return {
491
+ 'session.cwd': async (payload) => {
492
+ const { sessionId, cwd } = await cwdOf(payload)
493
+ return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
494
+ },
495
+ 'fs.tree': async (payload) => {
496
+ const { cwd } = await cwdOf(payload)
497
+ const record = payload as { path?: unknown }
498
+ const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings))
499
+ return listDirectory(target, resolved.listLimit)
500
+ },
501
+ 'fs.search': async (payload) => {
502
+ // The editor side panel's global name search: rooted at the session
503
+ // cwd (not caller-targetable — the walk is unbounded by design and
504
+ // must never escape the workspace), budgeted inside searchFiles.
505
+ const { cwd } = await cwdOf(payload)
506
+ const query = requireString(payload, 'query')
507
+ return searchFiles(cwd, query)
508
+ },
509
+ 'fs.read': async (payload) => {
510
+ const { cwd } = await cwdOf(payload)
511
+ // Relative paths are git-derived (status/diff report repo-root-relative
512
+ // names; the untracked diff view reads the file through this route). A
513
+ // child-repo path is relative to the selected repoRoot, not the session
514
+ // cwd; thread it so the path resolves inside the authorized workspace.
515
+ const selected = selectedRepoOf(payload)
516
+ const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected), fenceEnabledOf(getSettings))
517
+ const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
518
+ if (binary) return { kind: 'binary', size, truncated, head }
519
+ return { kind: 'text', content, truncated }
520
+ },
521
+ 'fs.write': async (payload) => {
522
+ const { cwd } = await cwdOf(payload)
523
+ const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings))
524
+ const content = requireString(payload, 'content')
525
+ const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
526
+ try {
527
+ await mkdir(dirname(path), { recursive: true })
528
+ await writeFile(tmp, content, 'utf8')
529
+ await rename(tmp, path)
530
+ } catch (error) {
531
+ await rm(tmp, { force: true }).catch(() => {})
532
+ throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
533
+ }
534
+ return { ok: true }
535
+ },
536
+ 'git.worktrees': async (payload) => {
537
+ const { cwd } = await gitCwdOf(payload)
538
+ const selected = selectedRepoOf(payload)
539
+ // A workspace container (no repo at cwd) has child repos; the worktree
540
+ // list belongs to the SELECTED child, not the container. Thread the
541
+ // validated repoRoot so linked checkouts of a chosen child appear.
542
+ const base = selected !== undefined ? await git.repoRoot(cwd, selected).catch(() => cwd) : cwd
543
+ return git.worktrees(base)
544
+ },
545
+ 'git.status': async (payload) => {
546
+ const { cwd } = await gitCwdOf(payload)
547
+ return git.status(cwd, selectedRepoOf(payload))
548
+ },
549
+ 'git.diff': async (payload) => {
550
+ const { cwd } = await gitCwdOf(payload)
551
+ const record = payload as { path?: unknown; staged?: unknown }
552
+ const repoRoot = selectedRepoOf(payload)
553
+ const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
554
+ return { diff: await git.diff(cwd, path, record.staged === true, repoRoot) }
555
+ },
556
+ 'git.stage': async (payload) => {
557
+ const { cwd } = await gitCwdOf(payload)
558
+ const record = payload as { path?: unknown }
559
+ const path = record.path === undefined ? undefined : requireString(payload, 'path')
560
+ await git.stage(cwd, path, selectedRepoOf(payload))
561
+ return { ok: true }
562
+ },
563
+ 'git.unstage': async (payload) => {
564
+ const { cwd } = await gitCwdOf(payload)
565
+ const record = payload as { path?: unknown }
566
+ const path = record.path === undefined ? undefined : requireString(payload, 'path')
567
+ await git.unstage(cwd, path, selectedRepoOf(payload))
568
+ return { ok: true }
569
+ },
570
+ 'git.commit': async (payload) => {
571
+ const { cwd } = await gitCwdOf(payload)
572
+ const message = requireString(payload, 'message')
573
+ await git.commit(cwd, message, selectedRepoOf(payload))
574
+ return { ok: true }
575
+ },
576
+ 'git.branch': async (payload) => {
577
+ const { cwd } = await gitCwdOf(payload)
578
+ return git.branches(cwd, selectedRepoOf(payload))
579
+ },
580
+ 'git.checkout': async (payload) => {
581
+ const { cwd } = await gitCwdOf(payload)
582
+ await git.checkout(cwd, requireString(payload, 'branch'), selectedRepoOf(payload))
583
+ return { ok: true }
584
+ },
585
+ 'git.log': async (payload) => {
586
+ const { cwd } = await gitCwdOf(payload)
587
+ const record = payload as { count?: unknown; skip?: unknown }
588
+ const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
589
+ ? record.count
590
+ : undefined
591
+ const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
592
+ ? record.skip
593
+ : undefined
594
+ return git.log(cwd, count, skip, selectedRepoOf(payload))
595
+ },
596
+ 'git.commit-diff': async (payload) => {
597
+ const { cwd } = await gitCwdOf(payload)
598
+ return { diff: await git.commitDiff(cwd, requireString(payload, 'hash'), selectedRepoOf(payload)) }
599
+ },
600
+ 'git.discard': async (payload) => {
601
+ const { cwd } = await gitCwdOf(payload)
602
+ const repoRoot = selectedRepoOf(payload)
603
+ await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot), repoRoot)
604
+ return { ok: true }
605
+ },
606
+ 'git.revert': async (payload) => {
607
+ const { cwd } = await gitCwdOf(payload)
608
+ await git.revert(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
609
+ return { ok: true }
610
+ },
611
+ 'git.cherry-pick': async (payload) => {
612
+ const { cwd } = await gitCwdOf(payload)
613
+ await git.cherryPick(cwd, requireString(payload, 'hash'), selectedRepoOf(payload))
614
+ return { ok: true }
615
+ },
616
+ 'git.show': async (payload) => {
617
+ const { cwd } = await gitCwdOf(payload)
618
+ const repoRoot = selectedRepoOf(payload)
619
+ const path = await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
620
+ const rev = requireString(payload, 'rev')
621
+ return { content: await git.show(cwd, rev, path, repoRoot) }
622
+ },
623
+ // Release a terminal immediately. The WebSocket close frame already does
624
+ // this while the socket is open; this route covers the tab-close that
625
+ // happens while the socket is down (reconnect loop), so a closed tab can
626
+ // never hold the per-session quota until the reconnect grace expires.
627
+ 'pty.close': (payload) => {
628
+ const sessionId = requireString(payload, 'sessionId')
629
+ const tab = requireString(payload, 'tab')
630
+ // Degraded mode (node-pty unavailable): no live pty can exist, so a
631
+ // no-op ok is the honest answer — never an error the client must show.
632
+ ptyManager?.close(`${sessionId}:${tab}`)
633
+ return { ok: true }
634
+ },
635
+ // Release an agent terminal by uuid. The WS close frame already does
636
+ // this while the socket is open; this route covers the tab-close that
637
+ // happens while the socket is down (reconnect loop) so a closed agent
638
+ // tab never leaves a zombie pty behind. Idempotent.
639
+ 'agent-pty.close': (payload) => {
640
+ const uuid = requireString(payload, 'uuid')
641
+ agentPtyRegistry?.close(uuid)
642
+ return { ok: true }
643
+ },
644
+ // Terminal dependency status (issue #140): after a WS close 1011 with
645
+ // reason `pty-deps-missing` the client fetches the full repair details
646
+ // here — the close reason itself is capped at 123 bytes, too small for
647
+ // the pasteable command.
648
+ 'terminal.deps': () => depsStatus(),
649
+ // The effective terminal shell and its display name. The client uses
650
+ // this to title terminal tabs with the shell name instead of a numbered
651
+ // "Terminal N" label; the shell itself is configured through
652
+ // `cordis.patch.yml` (`config.shell`) or resolved by the host default.
653
+ 'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
654
+ // Account identity belongs to the local DSH deployment, not a remote
655
+ // provider. The client uses this solely to label its bottom-left menu.
656
+ 'account.get': () => ({ userName }),
657
+ // The browser receives only account metadata. Tokens always remain in the
658
+ // native DSH credentials record held by the ChatCode host plugin.
659
+ 'chatcode.status': async () => {
660
+ const service = chatcodeAuth()
661
+ return service === undefined ? { available: false } : { available: true, ...await service.status() }
662
+ },
663
+ 'chatcode.start': () => requireChatcodeAuth().startLogin(),
664
+ 'chatcode.cancel': payload => requireChatcodeAuth().cancelLogin(requireString(payload, 'sessionId')).then(() => ({ ok: true })),
665
+ 'chatcode.logout': () => requireChatcodeAuth().logout().then(() => ({ ok: true })),
666
+ // Check both ChatCode packages (web runtime + CLI) against the npm
667
+ // registry and the globally installed version. Runs on the DSH host; a
668
+ // package not globally installed degrades to `current: null`.
669
+ 'chatcode.update.check': async () => ({ packages: await checkAllChatcodeUpdates() }),
670
+ // Upgrade both packages to their npm-latest versions. Re-checks server-side
671
+ // (the client never supplies versions to install) and reports per-package
672
+ // outcomes; a network failure on the version lookup surfaces as a
673
+ // SidebarError, npm non-zero exits are encoded per-package instead.
674
+ 'chatcode.update.perform': async () => ({ results: await performAllUpdates(await checkAllChatcodeUpdates()) }),
675
+ 'release-note.current': payload => {
676
+ const packageName = requireString(payload, 'packageName').trim()
677
+ const versionNum = requireString(payload, 'versionNum').trim()
678
+ if (packageName.length === 0 || packageName.length > 128 || versionNum.length === 0 || versionNum.length > 64) {
679
+ throw new SidebarError('bad-request', 'invalid packageName or versionNum')
680
+ }
681
+ return chatcodeReleaseNote(packageName, versionNum)
682
+ },
683
+ // Personal usage statistics, proxied server-side with the host-held
684
+ // token (the browser only ever sees the business data below).
685
+ 'chatcode.token-trend': async payload => {
686
+ const { begin, end } = chatcodeDayRangeOf(payload)
687
+ return chatcodeStatsData(requireChatcodeAuth(), '/token/userTokenTrend', { beginTime: begin, endTime: end })
688
+ },
689
+ 'chatcode.git-commit-stats': async payload => {
690
+ const { begin, end } = chatcodeDayRangeOf(payload)
691
+ return chatcodeStatsData(requireChatcodeAuth(), '/stats/gitCommitRecord', {
692
+ beginTime: `${begin} 00:00:00`,
693
+ endTime: `${end} 23:59:59`,
694
+ })
695
+ },
696
+ // The CodingPlan response may carry protocol/base URL/API-key fields.
697
+ // The catalogue has already reduced it to a display-only shape, so this
698
+ // route can never disclose a provider credential to the browser.
699
+ 'codingplan.models': () => codingPlanCatalogue.snapshot(),
700
+ 'codingplan.refresh': () => codingPlanCatalogue.refresh(),
701
+ // The side card preferences. The settings service is optional in the
702
+ // composition; while absent the routes report undefined and the client
703
+ // keeps the schema defaults. Writes are revision-guarded: a stale editor
704
+ // is refused with settings-conflict so a concurrent change is never
705
+ // silently overwritten (mirror of the settings seam's own guard).
706
+ 'settings.get': () => {
707
+ const settings = getSettings()
708
+ return settings === undefined
709
+ ? { value: undefined, revision: undefined, externalDisable: false }
710
+ : { ...settings.get(), externalDisable: settings.externalDisable() }
711
+ },
712
+ 'settings.update': async (payload) => {
713
+ const settings = getSettings()
714
+ if (settings === undefined) {
715
+ throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
716
+ }
717
+ const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
718
+ const patch = record?.patch
719
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
720
+ throw new SidebarError('bad-request', 'patch must be a plain object')
721
+ }
722
+ const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
723
+ try {
724
+ return await settings.update(patch as Record<string, unknown>, expectedRevision)
725
+ } catch (error) {
726
+ if (error instanceof SettingsConflictError) {
727
+ throw new SidebarError('settings-conflict', error.message, 409)
728
+ }
729
+ throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
730
+ }
731
+ },
732
+ // Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
733
+ // iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
734
+ // signals the browser enforces when it refuses to embed a site. The
735
+ // probe is display-only (headers back to the caller), restricted to
736
+ // http(s) non-loopback URLs with a hard timeout, and gated by the same
737
+ // trust fence as every other route — a cross-site page cannot reach it.
738
+ 'browser.probe': async (payload) => {
739
+ const raw = requireString(payload, 'url')
740
+ let parsed: URL
741
+ try {
742
+ parsed = new URL(raw)
743
+ } catch {
744
+ throw new SidebarError('bad-request', 'invalid url', 400)
745
+ }
746
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
747
+ throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
748
+ }
749
+ // Mirror the browser tab's address-bar policy: loopback stays unreachable
750
+ // from the sidebar (unless the user allowlisted it), so probing it would
751
+ // leak nothing the tab could use.
752
+ if (isLoopbackHostname(parsed.hostname)) {
753
+ const prefs = getSettings()?.get()?.value as SidebarPrefs | undefined
754
+ const allowlist = typeof prefs?.browserAllowedLoopback === 'string' ? prefs.browserAllowedLoopback : ''
755
+ const allowed = allowlist.trim() !== ''
756
+ && parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port)
757
+ if (!allowed) {
758
+ throw new SidebarError('bad-request', 'local addresses are not probed', 400)
759
+ }
760
+ }
761
+ const controller = new AbortController()
762
+ const timer = setTimeout(() => controller.abort(), 8000)
763
+ try {
764
+ let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
765
+ // Some servers answer HEAD with 405/501; retry once as GET (the
766
+ // body is discarded — only the headers matter).
767
+ let retriedFromHeadRejection = false
768
+ if (response.status === 405 || response.status === 501) {
769
+ response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
770
+ retriedFromHeadRejection = true
771
+ }
772
+ // Some servers (e.g. aliyun consoles) answer HEAD without the
773
+ // X-Frame-Options / CSP headers that only their GET response
774
+ // carries. Without those signals the embeddability check below
775
+ // would wrongly report the site as embeddable and the plain iframe
776
+ // would surface the browser's misleading "refused to connect".
777
+ // Retry once as GET when both signals are absent (body discarded).
778
+ // A 405/501 retry already fetched the GET response, so the signals
779
+ // are either there or genuinely absent — another GET adds nothing.
780
+ const hasEmbedSignals = response.headers.get('content-security-policy') !== null
781
+ || response.headers.get('x-frame-options') !== null
782
+ if (!hasEmbedSignals && !retriedFromHeadRejection && response.status !== 405 && response.status !== 501) {
783
+ response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
784
+ }
785
+ const csp = response.headers.get('content-security-policy')
786
+ const frameAncestors = extractFrameAncestors(csp)
787
+ const xFrameOptions = response.headers.get('x-frame-options')
788
+ // The GET fallbacks stream a real body that nothing reads; "body
789
+ // discarded" is not automatic with fetch, so cancel it explicitly to
790
+ // release the socket (a large/streaming response would otherwise stay
791
+ // pinned after the timer clears).
792
+ void response.body?.cancel()
793
+ return {
794
+ reachable: true,
795
+ url: response.url,
796
+ status: response.status,
797
+ ...(xFrameOptions !== null ? { xFrameOptions } : {}),
798
+ ...(frameAncestors !== undefined ? { frameAncestors } : {}),
799
+ }
800
+ } catch {
801
+ // DNS / TLS / connection / timeout: nothing to judge — the client
802
+ // keeps the plain iframe.
803
+ return { reachable: false }
804
+ } finally {
805
+ clearTimeout(timer)
806
+ }
807
+ },
808
+ // External open for the file tree's "open with" menu: reveal a path in
809
+ // the OS file manager, or hand a custom-scheme URL (vscode://,
810
+ // cursor://, zed://, custom editors) to its registered handler. The
811
+ // client is a browser renderer where raw scheme navigation is
812
+ // unreliable, so the launch always goes through the host — the same
813
+ // fence as every other route, argv-only (no shell interpolation).
814
+ 'open.external': (payload) => {
815
+ const record = payload as { action?: unknown } | null
816
+ const action = record?.action
817
+ if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path'))
818
+ if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
819
+ throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
820
+ },
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Plugin body: mount the fenced routes and the pty lifecycle.
826
+ * @param ctx - host plugin context (webServer, sessions, webRuntime).
827
+ * @param config - deployment-provided limits; the Loader validates against
828
+ * {@link Config} and fills defaults, direct callers get them from
829
+ * {@link resolveSidebarConfig}.
830
+ */
831
+ export function apply(ctx: Context, config?: SidebarConfig): void {
832
+ // dsh-ui-tweaks is embedded as an intact feature module. Its historical
833
+ // settings namespace and /_dsh/ui-tweaks routes stay unchanged so existing
834
+ // settings.yaml values and browser behavior carry over without migration.
835
+ // Cordis honours the root `inject` list in production. Keeping this guard
836
+ // lets lightweight embedders that call apply() directly retain the sidebar
837
+ // subset when they intentionally omit the settings service.
838
+ if ((ctx as Partial<Context>).settings !== undefined) applyUiTweaksHost(ctx)
839
+
840
+ // pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
841
+ // restore it before any terminal can spawn (idempotent).
842
+ ensureSpawnHelper()
843
+ const resolved = resolveSidebarConfig(config)
844
+ // One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
845
+ // model-facing terminal_* tools. They must stay in lockstep, otherwise a
846
+ // configured shell fixes one surface and silently leaves the other on the
847
+ // platform default.
848
+ const terminalShell = defaultShell({ explicit: resolved.shell })
849
+ // The web runtime's bind-derived trust list (boot-sampled LAN literals
850
+ // plus --trusted-host authorities) — the authoritative source the /api
851
+ // gateway fence derives its list from. Read per request from the live
852
+ // service value; a replaced list takes effect without a plugin restart.
853
+ const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
854
+ // node-pty is loaded lazily, never at module top level (issue #140): a
855
+ // missing or broken install must degrade THIS plugin — terminal tab shows
856
+ // a repair command, agent terminal tools stay unregistered — instead of
857
+ // failing the plugin load and taking the whole `dsh web` server down.
858
+ const nodePty = loadNodePty()
859
+ if (nodePty === null) {
860
+ const status = depsStatus()
861
+ const detail = status.ok
862
+ ? 'unknown cause'
863
+ : `${status.cause}. Repair: ${status.command}`
864
+ ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
865
+ }
866
+ const ptyManager = nodePty !== null
867
+ ? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty)
868
+ : null
869
+ // The agent-owned terminal registry: parallel to the UI-tab ptyManager,
870
+ // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
871
+ // uncapped, and torn down with the plugin. The model creates terminals here
872
+ // through the terminal_create tool; the sidebar view attaches through the
873
+ // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
874
+ const agentPtyRegistry = nodePty !== null
875
+ ? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
876
+ : null
877
+ // The model-facing open-request registry: queues `sidebar_open` requests
878
+ // per session and pushes them to connected sidebar views over the
879
+ // `/sidebar/ws/agent-opens` socket. Unlike the pty registry it has no
880
+ // native dependencies — the tool works even in node-pty degraded mode.
881
+ const agentOpenRegistry = new AgentOpenRegistry()
882
+ // Load the CodingPlan catalogue as the plugin starts. The first settings
883
+ // render receives a loading snapshot while this request is in flight; a
884
+ // transient failure remains a local UI state and never aborts plugin boot.
885
+ const codingPlanCatalogue = new CodingPlanCatalogue(resolved.codingPlan)
886
+ void codingPlanCatalogue.refresh().catch(() => { /* catalogue returns a safe unavailable snapshot */ })
887
+
888
+ // ── User-facing "Side card" preferences ──────────────────────────────────
889
+ // Register the namespace with the settings provider so the Settings page
890
+ // (client half) can render and persist the new-conversation defaults. The
891
+ // DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
892
+ // configuration clients, so the client reaches this namespace through the
893
+ // plugin's own fenced routes below ('settings.get'/'settings.update'),
894
+ // which call the seam in-process. Deployments without a settings service
895
+ // simply never fill the face and the client falls back to the defaults.
896
+ let settingsFace: SidebarSettingsFace | undefined
897
+ // The model-facing terminal tools are gated on the side-card setting
898
+ // `agentTerminalTools` (default off): nothing is injected until the user
899
+ // turns the feature on, and turning it off mid-session unregisters the
900
+ // tools and releases the agent terminals they created.
901
+ let toolsDisposers: (() => void) | null = null
902
+ // The model-facing `sidebar_open` tool is gated the same way (see
903
+ // syncOpenToolsGate below); separate disposer (no native deps, and turning
904
+ // the feature off must not release user terminals).
905
+ let openToolsDisposers: (() => void) | null = null
906
+ const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
907
+ if (scope.get().agentTerminalTools) {
908
+ if (toolsDisposers === null) {
909
+ // Degraded mode (node-pty unavailable): never register the terminal
910
+ // tools — every one of them would fail at spawn time.
911
+ if (agentPtyRegistry === null) return
912
+ toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace))
913
+ }
914
+ } else if (toolsDisposers !== null) {
915
+ toolsDisposers()
916
+ toolsDisposers = null
917
+ // The feature is off: release every agent terminal the model created
918
+ // while it was on (they are only reachable through the tools). The
919
+ // registry change fires the push, so the sidebar reconciles them away.
920
+ agentPtyRegistry?.disposeAll()
921
+ }
922
+ }
923
+ ctx.inject(['settings'], (sctx) => {
924
+ // DSH 0.1.2-alpha.2 validates namespaces at compile time
925
+ // (SettingsNamespaceInput); the 'dsh-better-sidebar' literal passes, so the
926
+ // runtime helper this used to call (settingsNamespace) is gone upstream.
927
+ const ns = SIDEBAR_PREFS_NS
928
+ // The structural settings mirror types `schema` as unknown, so the
929
+ // generic is not inferred here; the real service resolves it from the
930
+ // schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
931
+ const scope = sctx.settings.register(ns, PrefsSchema) as {
932
+ get(): SidebarPrefs
933
+ watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
934
+ }
935
+ const viewOf = (): { value?: unknown; revision?: number } => {
936
+ const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
937
+ return descriptor === undefined
938
+ ? { value: undefined, revision: undefined }
939
+ : { value: descriptor.value, revision: descriptor.revision }
940
+ }
941
+ // Mutual exclusion with the dsh-web-ui family right panel: the aionui
942
+ // panel's provider choice (`aionui-panel.rightPanel`) is the authority.
943
+ // While it resolves to 'aionui-panel', this sidebar must not mount. The
944
+ // namespace is read through the settings seam like any other registered
945
+ // section; absent namespace (no aionui installed) = not disabled.
946
+ const externalDisable = (): boolean => {
947
+ const descriptor = sctx.settings.describe({ redactSecrets: true })
948
+ .find(candidate => candidate.ns === 'aionui-panel')
949
+ const value = descriptor?.value as { rightPanel?: unknown } | undefined
950
+ return value?.rightPanel === 'aionui-panel'
951
+ }
952
+ settingsFace = {
953
+ get: viewOf,
954
+ externalDisable,
955
+ update: async (patch, expectedRevision) => {
956
+ await sctx.settings.update(ns, patch, expectedRevision)
957
+ return viewOf()
958
+ },
959
+ }
960
+ // Register (or unregister) the terminal tools from the current setting,
961
+ // and keep them in sync with every settings commit.
962
+ syncToolsGate(scope)
963
+ // The model-facing open tool is gated the same way on `agentOpenTools`
964
+ // (default off): nothing is injected until the user turns the feature
965
+ // on, and turning it off mid-session unregisters the tool and drops the
966
+ // queued (undelivered) open requests. Already-delivered opens keep their
967
+ // tabs — the tools' only lever is the queue, not the rendered state.
968
+ const syncOpenToolsGate = (): void => {
969
+ if (scope.get().agentOpenTools) {
970
+ if (openToolsDisposers === null) {
971
+ openToolsDisposers = registerOpenTool(
972
+ ctx,
973
+ agentOpenRegistry,
974
+ (sessionId) => sessionCwdOf(ctx, sessionId),
975
+ () => {
976
+ const view = settingsFace?.get()
977
+ const value = view?.value
978
+ return value !== null && typeof value === 'object'
979
+ ? value as SidebarPrefs
980
+ : SIDEBAR_PREFS_DEFAULTS
981
+ },
982
+ )
983
+ }
984
+ } else if (openToolsDisposers !== null) {
985
+ openToolsDisposers()
986
+ openToolsDisposers = null
987
+ agentOpenRegistry.drainAll()
988
+ }
989
+ }
990
+ syncOpenToolsGate()
991
+ // ONE watch subscription drives both gates: settings commits re-evaluate
992
+ // the terminal tools AND the open tool together (each gate is idempotent
993
+ // and owns its own disposer).
994
+ scope.watch(() => { syncToolsGate(scope); syncOpenToolsGate() })
995
+ })
996
+
997
+ // ── JSON API ────────────────────────────────────────────────────────────
998
+ const api = buildApi(
999
+ ctx,
1000
+ ptyManager,
1001
+ agentPtyRegistry,
1002
+ resolved,
1003
+ terminalShell,
1004
+ () => settingsFace,
1005
+ resolved.userName,
1006
+ codingPlanCatalogue,
1007
+ )
1008
+ ctx.effect(() => ctx.webServer.register({
1009
+ kind: 'prefix',
1010
+ path: '/sidebar/api',
1011
+ handler: async (req, res) => {
1012
+ if (!fence(req)) {
1013
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
1014
+ return
1015
+ }
1016
+ if (req.method !== 'POST') {
1017
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
1018
+ return
1019
+ }
1020
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
1021
+ const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
1022
+ if (method === undefined || method.includes('/')) {
1023
+ writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
1024
+ return
1025
+ }
1026
+ try {
1027
+ const payload = await readJsonBody(req)
1028
+ const handler = api[method]
1029
+ if (handler === undefined) {
1030
+ throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
1031
+ }
1032
+ writeOk(res, await handler(payload))
1033
+ } catch (error) {
1034
+ writeError(res, error)
1035
+ }
1036
+ },
1037
+ }), 'dsh-better-sidebar: /sidebar/api routes')
1038
+ ctx.effect(() => registerLoginRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/login route')
1039
+
1040
+ // ── Raw upload route ───────────────────────────────────────────────────
1041
+ // One request writes one file without JSON/base64 inflation. Folder uploads
1042
+ // send each file with a relativePath, preserving the selected directory
1043
+ // tree. Bytes stream to a temp sibling and are renamed into place, so a
1044
+ // failed or oversized upload never leaves a partial file (see
1045
+ // fs-operations.ts for the containment and shape rules).
1046
+ ctx.effect(() => ctx.webServer.register({
1047
+ kind: 'exact',
1048
+ path: '/sidebar/upload',
1049
+ handler: async (req, res) => {
1050
+ if (!fence(req)) {
1051
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
1052
+ return
1053
+ }
1054
+ if (req.method !== 'POST') {
1055
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
1056
+ return
1057
+ }
1058
+ try {
1059
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
1060
+ const sessionId = url.searchParams.get('sessionId')
1061
+ const dir = url.searchParams.get('dir')
1062
+ const relativePath = url.searchParams.get('relativePath')
1063
+ if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === '') {
1064
+ throw new SidebarError('bad-request', 'sessionId, dir, and relativePath are required')
1065
+ }
1066
+ const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
1067
+ const { path, size } = await writeWorkspaceUpload({
1068
+ cwd,
1069
+ dir,
1070
+ relativePath,
1071
+ chunks: req,
1072
+ limit: resolved.uploadLimit,
1073
+ fence: fenceEnabledOf(() => settingsFace),
1074
+ })
1075
+ writeOk(res, { path, size })
1076
+ } catch (error) {
1077
+ writeError(res, error)
1078
+ }
1079
+ },
1080
+ }), 'dsh-better-sidebar: /sidebar/upload route')
1081
+
1082
+ // ── Lazy chunk route (client bundle splits) ─────────────────────────────
1083
+ // Serves the client half's split bundles (lib/client-<name>.js) so the
1084
+ // heavy preview/terminal libraries load on first use, not at page start
1085
+ // (see bundle-route.ts / src/client/chunk-loader.ts).
1086
+ ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
1087
+
1088
+ // ── Media route (images for the editor) ─────────────────────────────────
1089
+ ctx.effect(() => ctx.webServer.register({
1090
+ kind: 'prefix',
1091
+ path: '/sidebar/file',
1092
+ handler: async (req, res) => {
1093
+ if (!fence(req)) {
1094
+ res.writeHead(403)
1095
+ res.end('forbidden')
1096
+ return
1097
+ }
1098
+ if (req.method !== 'GET') {
1099
+ res.writeHead(405)
1100
+ res.end()
1101
+ return
1102
+ }
1103
+ try {
1104
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
1105
+ const sessionId = url.searchParams.get('sessionId')
1106
+ const raw = url.searchParams.get('path')
1107
+ if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
1108
+ const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
1109
+ const path = await ensureWorkspacePath(cwd, raw, fenceEnabledOf(() => settingsFace))
1110
+ const info = await stat(path)
1111
+ if (!info.isFile() || info.size > resolved.mediaLimit) {
1112
+ throw new SidebarError('fs-error', 'not a file or too large', 400)
1113
+ }
1114
+ const type = mediaTypeForPath(path)
1115
+ const body = await readFile(path)
1116
+ // Raw bytes either way (binary-safe); ?download=1 switches the
1117
+ // disposition so the browser saves the file instead of showing it.
1118
+ const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
1119
+ if (url.searchParams.get('download') === '1') {
1120
+ headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
1121
+ }
1122
+ res.writeHead(200, headers)
1123
+ res.end(body)
1124
+ } catch (error) {
1125
+ writeError(res, error)
1126
+ }
1127
+ },
1128
+ }), 'dsh-better-sidebar: /sidebar/file media route')
1129
+
1130
+ // ── HTML preview route (sandboxed HTML + its relative assets) ───────────
1131
+ // Serves files under the session cwd for the built-in HTML previewer. The
1132
+ // URL is path-encoded (see html-route.ts) so the previewed page's relative
1133
+ // assets (./style.css, img/x.png) resolve back into this route with the
1134
+ // session scope intact — a query-encoded URL would drop the scope when the
1135
+ // browser resolves relatives. Every response carries the CSP `sandbox`
1136
+ // directive: inside the editor's iframe the sandbox ATTRIBUTE is the
1137
+ // boundary, this header is defense-in-depth so even a top-level load of
1138
+ // the URL (e.g. a popup opened by a previewed page) stays in an opaque
1139
+ // origin with no same-origin access to the GUI.
1140
+ ctx.effect(() => ctx.webServer.register({
1141
+ kind: 'prefix',
1142
+ path: '/sidebar/html',
1143
+ handler: async (req, res) => {
1144
+ if (!fence(req)) {
1145
+ res.writeHead(403)
1146
+ res.end('forbidden')
1147
+ return
1148
+ }
1149
+ if (req.method !== 'GET') {
1150
+ res.writeHead(405)
1151
+ res.end()
1152
+ return
1153
+ }
1154
+ try {
1155
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
1156
+ const decoded = decodeHtmlUrl(url.pathname)
1157
+ if (!decoded.ok) {
1158
+ writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
1159
+ return
1160
+ }
1161
+ const { sessionId, path } = decoded.ref
1162
+ // The session's authoritative cwd (client cwd cannot ride in the URL
1163
+ // — the path encoding has no query; a detached first request falls
1164
+ // back to the process cwd and is normally refused by the workspace
1165
+ // real-path guard, with the same semantics as the media route's
1166
+ // fallback.
1167
+ const cwd = await sessionCwdOf(ctx, sessionId)
1168
+ const absolute = await ensureWorkspacePath(cwd, path, fenceEnabledOf(() => settingsFace))
1169
+ const info = await stat(absolute)
1170
+ if (!info.isFile() || info.size > resolved.mediaLimit) {
1171
+ throw new SidebarError('fs-error', 'not a file or too large', 400)
1172
+ }
1173
+ const type = mediaTypeForPath(absolute)
1174
+ const body = await readFile(absolute)
1175
+ res.writeHead(200, {
1176
+ 'content-type': type === 'text/html' ? 'text/html; charset=utf-8' : type,
1177
+ 'cache-control': 'no-cache',
1178
+ 'x-content-type-options': 'nosniff',
1179
+ 'referrer-policy': 'no-referrer',
1180
+ // The sandbox directive (no allow-same-origin → opaque origin) is
1181
+ // the previewer's security boundary even for top-level loads;
1182
+ // object-src 'none' blocks plugin embeds.
1183
+ 'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
1184
+ })
1185
+ res.end(body)
1186
+ } catch (error) {
1187
+ writeError(res, error)
1188
+ }
1189
+ },
1190
+ }), 'dsh-better-sidebar: /sidebar/html preview route')
1191
+
1192
+ // ── Terminal WebSocket ──────────────────────────────────────────────────
1193
+ // One upgrade endpoint serves both UI-tab terminals (?tab=...) and
1194
+ // agent-owned terminals (?uuid=...). The two paths attach to different
1195
+ // registries but share the wire protocol: input frames are raw text,
1196
+ // resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
1197
+ // `{type:'close'}` releases the underlying pty (immediate for agent
1198
+ // terminals, scheduled-0 for UI tabs which keep the same reconnect grace
1199
+ // contract the host has always had).
1200
+ const wss = new WebSocketServer({ noServer: true })
1201
+ ctx.effect(() => ctx.webServer.registerUpgrade({
1202
+ path: '/sidebar/ws/terminal',
1203
+ handler: (req, socket, head) => {
1204
+ if (!fence(req)) {
1205
+ socket.destroy()
1206
+ return
1207
+ }
1208
+ // The structural request/socket/head faces satisfy the shared fence;
1209
+ // the `ws` package wants the real Node types — cast at this boundary.
1210
+ wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
1211
+ void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace)
1212
+ })
1213
+ },
1214
+ }), 'dsh-better-sidebar: terminal WebSocket')
1215
+
1216
+ // ── Agent terminals push WebSocket ──────────────────────────────────────
1217
+ // Pushes the live list of agent terminals for one session to the sidebar
1218
+ // view: the client mirrors the list into tabs (id `agent:<uuid>`,
1219
+ // title from the agent's `terminal_create` call). The host fires on every
1220
+ // create / close / exit; the client reconciles by adding tabs for new
1221
+ // uuids and dropping tabs whose uuids disappeared (the user closing a tab
1222
+ // sends `{type:'close'}` on the terminal WS, which kills the pty, which
1223
+ // fires a change here, which converges the view).
1224
+ const agentListWss = new WebSocketServer({ noServer: true })
1225
+ ctx.effect(() => ctx.webServer.registerUpgrade({
1226
+ path: '/sidebar/ws/agent-terminals',
1227
+ handler: (req, socket, head) => {
1228
+ if (!fence(req)) {
1229
+ socket.destroy()
1230
+ return
1231
+ }
1232
+ agentListWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
1233
+ void attachAgentList(agentPtyRegistry, ws, req)
1234
+ })
1235
+ },
1236
+ }), 'dsh-better-sidebar: agent-terminals push WebSocket')
1237
+
1238
+ // ── Agent opens push WebSocket ─────────────────────────────────────────
1239
+ // Pushes `sidebar_open` requests for one session to the sidebar view: the
1240
+ // host queues each request in the registry (consume-on-send), so a
1241
+ // connected view applies it immediately and a disconnected one gets the
1242
+ // replay when it attaches. The client mirrors each request into an
1243
+ // editor / folder-window / browser tab open.
1244
+ const agentOpenWss = new WebSocketServer({ noServer: true })
1245
+ ctx.effect(() => ctx.webServer.registerUpgrade({
1246
+ path: '/sidebar/ws/agent-opens',
1247
+ handler: (req, socket, head) => {
1248
+ if (!fence(req)) {
1249
+ socket.destroy()
1250
+ return
1251
+ }
1252
+ agentOpenWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
1253
+ void attachAgentOpen(agentOpenRegistry, ws, req)
1254
+ })
1255
+ },
1256
+ }), 'dsh-better-sidebar: agent-opens push WebSocket')
1257
+
1258
+ ctx.effect(() => () => {
1259
+ toolsDisposers?.()
1260
+ openToolsDisposers?.()
1261
+ ptyManager?.disposeAll()
1262
+ agentPtyRegistry?.disposeAll()
1263
+ agentOpenRegistry.dispose()
1264
+ wss.close()
1265
+ agentListWss.close()
1266
+ agentOpenWss.close()
1267
+ }, 'dsh-better-sidebar: teardown')
1268
+ }
1269
+
1270
+ /** Push queued `sidebar_open` requests for one session to a connected view. */
1271
+ async function attachAgentOpen(
1272
+ registry: AgentOpenRegistry,
1273
+ ws: WebSocket,
1274
+ req: SidebarHttpRequest,
1275
+ ): Promise<void> {
1276
+ try {
1277
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
1278
+ const sessionId = url.searchParams.get('sessionId')
1279
+ if (sessionId === null) {
1280
+ ws.close(1008, 'sessionId is required')
1281
+ return
1282
+ }
1283
+ const send = (request: AgentOpenRequest): void => {
1284
+ if (ws.readyState === WebSocket.OPEN) {
1285
+ ws.send(JSON.stringify(request))
1286
+ }
1287
+ }
1288
+ // Attach replays the queued (undelivered) requests for this session; the
1289
+ // disposer detaches the view on socket close/error so later opens queue
1290
+ // instead of accumulating on a dead socket.
1291
+ const unsubscribe = registry.attach(sessionId, send)
1292
+ ws.on('close', () => { unsubscribe() })
1293
+ ws.on('error', () => { unsubscribe() })
1294
+ } catch (error) {
1295
+ ws.close(1011, error instanceof Error ? error.message : String(error))
1296
+ }
1297
+ }
1298
+
1299
+ /** Push the live agent-terminal list for one session to a connected sidebar view. */
1300
+ async function attachAgentList(
1301
+ registry: AgentPtyRegistry | null,
1302
+ ws: WebSocket,
1303
+ req: SidebarHttpRequest,
1304
+ ): Promise<void> {
1305
+ try {
1306
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
1307
+ const sessionId = url.searchParams.get('sessionId')
1308
+ if (sessionId === null) {
1309
+ ws.close(1008, 'sessionId is required')
1310
+ return
1311
+ }
1312
+ const send = (): void => {
1313
+ if (ws.readyState === WebSocket.OPEN) {
1314
+ // Degraded mode (node-pty unavailable): no agent terminal can exist,
1315
+ // so the honest push is the empty list.
1316
+ ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
1317
+ }
1318
+ }
1319
+ send()
1320
+ const unsubscribe = registry?.subscribe(send)
1321
+ ws.on('close', () => { unsubscribe?.() })
1322
+ ws.on('error', () => { unsubscribe?.() })
1323
+ } catch (error) {
1324
+ ws.close(1011, error instanceof Error ? error.message : String(error))
1325
+ }
1326
+ }
1327
+
1328
+ /**
1329
+ * Wire one terminal socket to its pty: replay transcript, pump both ways.
1330
+ * Two attach modes share the wire protocol:
1331
+ * - `?uuid=...` attaches to an agent-owned terminal (created by the
1332
+ * `terminal_create` tool). The close frame kills the pty immediately
1333
+ * (the agent's terminal closes when the user closes the sidebar tab); a
1334
+ * bare socket drop (refresh, tab switch) leaves the pty alive for the
1335
+ * reconnect grace, exactly like UI-tab terminals.
1336
+ * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
1337
+ * created it from the + menu). The close frame schedules a 0-ms close
1338
+ * (the host's reconnect grace keeps the shell alive across a refresh).
1339
+ * The park frame (sent when the user switches to another conversation)
1340
+ * marks the pty as parked so the upcoming bare socket drop does NOT start
1341
+ * the grace countdown — the tab is still open in its session's state, so
1342
+ * the shell must survive until the user switches back or closes the tab.
1343
+ */
1344
+ async function attachTerminal(
1345
+ ctx: Context,
1346
+ ptyManager: PtyManager | null,
1347
+ agentPtyRegistry: AgentPtyRegistry | null,
1348
+ ws: WebSocket,
1349
+ req: SidebarHttpRequest,
1350
+ resolved: ResolvedSidebarConfig,
1351
+ getSettings: () => SidebarSettingsFace | undefined,
1352
+ ): Promise<void> {
1353
+ try {
1354
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
1355
+ const uuid = url.searchParams.get('uuid')
1356
+ if (uuid !== null) {
1357
+ // Degraded mode (node-pty unavailable): no agent terminal can exist,
1358
+ // so the lookup behaves exactly like a missing uuid.
1359
+ if (agentPtyRegistry === null) {
1360
+ ws.close(1011, `agent terminal "${uuid}" not found`)
1361
+ return
1362
+ }
1363
+ const handle = agentPtyRegistry.get(uuid)
1364
+ if (handle === undefined) {
1365
+ ws.close(1011, `agent terminal "${uuid}" not found`)
1366
+ return
1367
+ }
1368
+ pumpAgentTerminal(agentPtyRegistry, handle, ws)
1369
+ return
1370
+ }
1371
+ const sessionId = url.searchParams.get('sessionId')
1372
+ const tabId = url.searchParams.get('tab')
1373
+ if (sessionId === null || tabId === null) {
1374
+ ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
1375
+ return
1376
+ }
1377
+ if (ptyManager === null) {
1378
+ // Degraded mode (issue #140): node-pty unavailable. The close reason
1379
+ // is a SHORT marker — a WS close reason is capped at 123 bytes, so the
1380
+ // client fetches the full repair command from /sidebar/api/terminal.deps.
1381
+ ws.close(1011, PTY_DEPS_MISSING)
1382
+ return
1383
+ }
1384
+ const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
1385
+ // Settings-page shell overrides win over the yaml/auto shell for
1386
+ // terminals opened from now on (existing pty handles keep their shell).
1387
+ const overrides = shellOverridesOf(getSettings)
1388
+ const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
1389
+ // Replay the transcript, then follow live output.
1390
+ if (handle.transcript !== '') ws.send(handle.transcript)
1391
+ const onData = (data: string): void => {
1392
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
1393
+ ws.send(data)
1394
+ }
1395
+ }
1396
+ const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
1397
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
1398
+ }
1399
+ const dataSub = handle.pty.onData(onData)
1400
+ const exitSub = handle.pty.onExit(onExit)
1401
+ ws.on('message', (data) => {
1402
+ const text = data.toString('utf8')
1403
+ // Control frames are JSON with a known shape; anything else (including
1404
+ // JSON that is not a recognized control) is terminal input, verbatim.
1405
+ let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
1406
+ try {
1407
+ const parsed: unknown = JSON.parse(text)
1408
+ if (parsed !== null && typeof parsed === 'object') {
1409
+ control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
1410
+ }
1411
+ } catch {
1412
+ // Not JSON: terminal input.
1413
+ }
1414
+ if (control !== null && control.type === 'close') {
1415
+ // The owning tab was closed: release the quota immediately.
1416
+ ptyManager.scheduleClose(handle.key, 0)
1417
+ return
1418
+ }
1419
+ if (control !== null && control.type === 'park') {
1420
+ // The user switched to another conversation: the tab is still open in
1421
+ // its session's persisted state, but its view unmounted. Park the pty
1422
+ // so the upcoming bare socket drop does NOT start the reconnect-grace
1423
+ // countdown — the pty stays alive until the user switches back (a
1424
+ // reconnecting view clears the parked state) or explicitly closes the
1425
+ // tab (a close frame's scheduleClose clears it).
1426
+ ptyManager.park(handle.key)
1427
+ return
1428
+ }
1429
+ if (handle.exited) return
1430
+ if (
1431
+ control !== null
1432
+ && control.type === 'resize'
1433
+ && typeof control.cols === 'number' && typeof control.rows === 'number'
1434
+ ) {
1435
+ const dims = clampDims(control.cols, control.rows)
1436
+ handle.pty.resize(dims.cols, dims.rows)
1437
+ } else {
1438
+ handle.pty.write(text)
1439
+ }
1440
+ })
1441
+ ws.on('close', () => {
1442
+ dataSub.dispose()
1443
+ exitSub.dispose()
1444
+ // A parked pty (the user switched conversations and sent `{type:'park'}`)
1445
+ // stays alive indefinitely — do NOT start the grace countdown. A bare
1446
+ // socket drop without a prior park (refresh, crash) starts the grace
1447
+ // period so a quick reconnect keeps the process; the reconnect's open()
1448
+ // cancels the pending close.
1449
+ if (!ptyManager.isParked(handle.key)) {
1450
+ ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
1451
+ }
1452
+ })
1453
+ } catch (error) {
1454
+ ws.close(1011, error instanceof Error ? error.message : String(error))
1455
+ }
1456
+ }
1457
+
1458
+ /**
1459
+ * Pump one agent terminal's pty to a connected view. The close frame kills
1460
+ * the pty immediately (the agent's terminal closes when the user closes the
1461
+ * sidebar tab); a bare socket drop leaves the pty alive — the agent owns
1462
+ * the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
1463
+ * plugin teardown kills it.
1464
+ */
1465
+ function pumpAgentTerminal(
1466
+ registry: AgentPtyRegistry,
1467
+ handle: AgentTerminalHandle,
1468
+ ws: WebSocket,
1469
+ ): void {
1470
+ if (handle.transcript !== '') ws.send(handle.transcript)
1471
+ const onData = (data: string): void => {
1472
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
1473
+ ws.send(data)
1474
+ }
1475
+ }
1476
+ const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
1477
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
1478
+ }
1479
+ const dataSub = handle.pty.onData(onData)
1480
+ const exitSub = handle.pty.onExit(onExit)
1481
+ ws.on('message', (data) => {
1482
+ if (handle.exited) return
1483
+ const text = data.toString('utf8')
1484
+ let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
1485
+ try {
1486
+ const parsed: unknown = JSON.parse(text)
1487
+ if (parsed !== null && typeof parsed === 'object') {
1488
+ control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
1489
+ }
1490
+ } catch {
1491
+ // Not JSON: terminal input.
1492
+ }
1493
+ if (control !== null && control.type === 'close') {
1494
+ // The user closed the sidebar tab: kill the pty immediately. The
1495
+ // agent's next terminal_list / terminal_send will see it gone.
1496
+ registry.close(handle.uuid)
1497
+ return
1498
+ }
1499
+ if (
1500
+ control !== null
1501
+ && control.type === 'resize'
1502
+ && typeof control.cols === 'number' && typeof control.rows === 'number'
1503
+ ) {
1504
+ const dims = clampDims(control.cols, control.rows)
1505
+ handle.pty.resize(dims.cols, dims.rows)
1506
+ } else if (control === null) {
1507
+ // Raw text input (a JSON-looking string the pty would have received
1508
+ // verbatim is reachable in theory but is exotic for an agent terminal;
1509
+ // preserve the UI-tab semantics and forward as input).
1510
+ handle.pty.write(text)
1511
+ }
1512
+ // An unrecognized JSON control frame is dropped (the UI-tab path also
1513
+ // treats non-resize JSON controls as input, but for an agent terminal
1514
+ // there is no realistic input that is also valid JSON).
1515
+ })
1516
+ ws.on('close', () => {
1517
+ dataSub.dispose()
1518
+ exitSub.dispose()
1519
+ // A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
1520
+ // The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
1521
+ // frame, or plugin teardown kills it. A reconnecting view reattaches the
1522
+ // same shell and gets the full transcript replayed.
1523
+ })
1524
+ }