dsh-coding-sidebar 1.0.0

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