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/lib/index.js ADDED
@@ -0,0 +1,4556 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { WebSocket, WebSocketServer } from "ws";
5
+ import z from "schemastery";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { once } from "node:events";
8
+ import { chmodSync, createWriteStream, existsSync, readFileSync, realpathSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { spawn } from "node:child_process";
11
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
12
+ import { homedir, userInfo } from "node:os";
13
+ import { defineTool } from "@deepseek-ai/dsh-tools";
14
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
15
+ import { snapshotSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
16
+ //#region src/prefs-shared.ts
17
+ /**
18
+ * Shared "Side card" preference vocabulary (types + constants), consumed by
19
+ * BOTH halves: the host registers the schemastery schema over these values
20
+ * (config.ts) and the client reads/writes them through the settings RPC
21
+ * (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
22
+ * the browser bundle never pulls the schema runtime in.
23
+ */
24
+ /** The user-settings namespace holding the side card preferences. */
25
+ const SIDEBAR_PREFS_NS = "dsh-coding-sidebar";
26
+ /** Fallback prefs used whenever the settings document is unreachable or malformed. */
27
+ const SIDEBAR_PREFS_DEFAULTS = {
28
+ openByDefault: false,
29
+ defaultWidthPercent: 35,
30
+ autoOpenSubagent: true,
31
+ autoOpenJobs: true,
32
+ agentTerminalTools: false,
33
+ agentOpenTools: false,
34
+ terminalFontFamily: "",
35
+ terminalFontSize: 13,
36
+ interceptOpenPath: true,
37
+ editorExplorer: false,
38
+ terminalShell: "",
39
+ terminalShellArgs: "",
40
+ titleBarScheme: "auto",
41
+ titleBarPresetId: "",
42
+ customCss: "",
43
+ titleBarCompat: false,
44
+ titleBarStripPx: 40,
45
+ htmlViewerNoSandbox: false,
46
+ htmlViewerDefaultUnsafe: false,
47
+ browserNoSandbox: false,
48
+ browserInterceptLinks: true,
49
+ browserInterceptHttp: true,
50
+ browserInterceptHttps: false,
51
+ browserAllowedLoopback: "",
52
+ tabsEnabled: {},
53
+ viewersEnabled: {},
54
+ pluginSettings: {}
55
+ };
56
+ //#endregion
57
+ //#region src/config.ts
58
+ /**
59
+ * Serializable configuration and defaults for the sidebar host half. Loader
60
+ * schema validation normally fills defaults; {@link resolveSidebarConfig}
61
+ * applies the same defaults for direct callers that bypass the Loader.
62
+ * @module dsh-coding-sidebar/config
63
+ */
64
+ /** Schemastery schema for the plugin configuration. */
65
+ const Config = z.object({
66
+ readLimit: z.number().step(1).min(1).default(524288),
67
+ mediaLimit: z.number().step(1).min(1).default(20971520),
68
+ uploadLimit: z.number().step(1).min(1).default(134217728),
69
+ listLimit: z.number().step(1).min(1).default(1e3),
70
+ terminalsPerSession: z.number().step(1).min(1).default(3),
71
+ reconnectGraceMs: z.number().step(1).min(0).default(3e4),
72
+ shell: z.string().default(""),
73
+ shellArgs: z.array(z.string()).default([])
74
+ });
75
+ /**
76
+ * Apply direct-call defaults after Loader schema validation has normally run.
77
+ *
78
+ * @param config - Deployment-provided sidebar host settings.
79
+ * @returns Complete settings consumed by the host half.
80
+ */
81
+ function resolveSidebarConfig(config) {
82
+ return {
83
+ readLimit: config?.readLimit ?? 524288,
84
+ mediaLimit: config?.mediaLimit ?? 20971520,
85
+ uploadLimit: config?.uploadLimit ?? 134217728,
86
+ listLimit: config?.listLimit ?? 1e3,
87
+ terminalsPerSession: config?.terminalsPerSession ?? 3,
88
+ reconnectGraceMs: config?.reconnectGraceMs ?? 3e4,
89
+ shell: config?.shell?.trim() ?? "",
90
+ shellArgs: config?.shellArgs ?? []
91
+ };
92
+ }
93
+ /** Schemastery schema for the user-facing preferences (validated by the settings service). */
94
+ const PrefsSchema = z.object({
95
+ openByDefault: z.boolean().default(false),
96
+ defaultWidthPercent: z.number().step(1).min(20).max(60).default(35),
97
+ autoOpenSubagent: z.boolean().default(true),
98
+ autoOpenJobs: z.boolean().default(true),
99
+ agentTerminalTools: z.boolean().default(false),
100
+ agentOpenTools: z.boolean().default(false),
101
+ terminalFontFamily: z.string().default(""),
102
+ terminalFontSize: z.number().step(1).min(9).max(32).default(13),
103
+ interceptOpenPath: z.boolean().default(true),
104
+ editorExplorer: z.boolean().default(false),
105
+ terminalShell: z.string().default(""),
106
+ terminalShellArgs: z.string().default(""),
107
+ titleBarScheme: z.union([
108
+ z.const("auto"),
109
+ z.const("web"),
110
+ z.const("preset"),
111
+ z.const("custom")
112
+ ]),
113
+ titleBarPresetId: z.string(),
114
+ customCss: z.string(),
115
+ titleBarCompat: z.boolean().default(false),
116
+ titleBarStripPx: z.number().step(1).min(0).max(120).default(40),
117
+ htmlViewerNoSandbox: z.boolean().default(false),
118
+ htmlViewerDefaultUnsafe: z.boolean().default(false),
119
+ browserNoSandbox: z.boolean().default(false),
120
+ browserInterceptLinks: z.boolean().default(true),
121
+ browserInterceptHttp: z.boolean().default(true),
122
+ browserInterceptHttps: z.boolean().default(false),
123
+ browserAllowedLoopback: z.string().default(""),
124
+ tabsEnabled: z.dict(z.boolean()).default({}),
125
+ viewersEnabled: z.dict(z.boolean()).default({}),
126
+ pluginSettings: z.dict(z.dict(z.any())).default({})
127
+ });
128
+ //#endregion
129
+ //#region src/wire.ts
130
+ /** One API failure with its wire code and HTTP status. */
131
+ var SidebarError = class extends Error {
132
+ code;
133
+ status;
134
+ constructor(code, message, status = 400) {
135
+ super(message);
136
+ this.code = code;
137
+ this.status = status;
138
+ }
139
+ };
140
+ /** Body size bound of one JSON request (defense against unbounded reads). */
141
+ const MAX_BODY_BYTES = 1 << 20;
142
+ /** Read and parse the JSON request body (bounded; malformed → bad-request). */
143
+ async function readJsonBody(req) {
144
+ const chunks = [];
145
+ let total = 0;
146
+ for await (const chunk of req) {
147
+ const buffer = Buffer.from(chunk);
148
+ total += buffer.length;
149
+ if (total > MAX_BODY_BYTES) throw new SidebarError("bad-request", "request body too large");
150
+ chunks.push(buffer);
151
+ }
152
+ const text = Buffer.concat(chunks).toString("utf8");
153
+ if (text.trim() === "") return {};
154
+ try {
155
+ return JSON.parse(text);
156
+ } catch {
157
+ throw new SidebarError("bad-request", "request body is not valid JSON");
158
+ }
159
+ }
160
+ /** Write a JSON response with the given status. */
161
+ function writeJson(res, status, body) {
162
+ const payload = JSON.stringify(body);
163
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
164
+ res.end(payload);
165
+ }
166
+ /** Write the success envelope. */
167
+ function writeOk(res, value) {
168
+ writeJson(res, 200, {
169
+ ok: true,
170
+ value
171
+ });
172
+ }
173
+ /** Write the failure envelope for any thrown value (unknown → internal 500). */
174
+ function writeError(res, error) {
175
+ if (error instanceof SidebarError) {
176
+ writeJson(res, error.status, {
177
+ ok: false,
178
+ error: {
179
+ code: error.code,
180
+ message: error.message
181
+ }
182
+ });
183
+ return;
184
+ }
185
+ writeJson(res, 500, {
186
+ ok: false,
187
+ error: {
188
+ code: "internal",
189
+ message: error instanceof Error ? error.message : String(error)
190
+ }
191
+ });
192
+ }
193
+ /** Narrow an unknown payload value to a string, else throw bad-request. */
194
+ function requireString(payload, key) {
195
+ const value = payload?.[key];
196
+ if (typeof value !== "string" || value === "") throw new SidebarError("bad-request", `missing or invalid "${key}"`);
197
+ return value;
198
+ }
199
+ //#endregion
200
+ //#region src/fs-tree.ts
201
+ /**
202
+ * Single-level directory listing for the sidebar explorer. Streams the level
203
+ * with opendir, sorts directories first then names (case-insensitive), and
204
+ * marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
205
+ * stat'ed once to expose their target kind — a symlink to a directory
206
+ * expands like a directory — and dangling links are flagged broken. The
207
+ * probe runs only for entries that are actually symlinks, so levels without
208
+ * links stay as cheap as before.
209
+ */
210
+ /** Directory-first, case-insensitive name ordering (VSCode explorer order). */
211
+ function compareEntries(a, b) {
212
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
213
+ return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
214
+ }
215
+ /**
216
+ * List one directory level.
217
+ * @param path - absolute directory path.
218
+ * @param maxEntries - row bound of one level (extra rows flag `truncated`).
219
+ * @returns the sorted listing.
220
+ * @throws {SidebarError} fs-error when the level is unreadable or not a directory.
221
+ */
222
+ async function listDirectory(path, maxEntries = 1e3) {
223
+ let level;
224
+ try {
225
+ level = await opendir(path);
226
+ } catch (error) {
227
+ throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
228
+ }
229
+ const rows = [];
230
+ let overflow = 0;
231
+ try {
232
+ for await (const dirent of level) {
233
+ if (rows.length >= maxEntries) {
234
+ overflow += 1;
235
+ continue;
236
+ }
237
+ rows.push({
238
+ name: dirent.name,
239
+ path: join(path, dirent.name),
240
+ isDir: dirent.isDirectory(),
241
+ isSymlink: dirent.isSymbolicLink(),
242
+ broken: false,
243
+ hidden: dirent.name.startsWith(".")
244
+ });
245
+ }
246
+ } catch (error) {
247
+ throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
248
+ }
249
+ await probeSymlinkTargets(rows);
250
+ rows.sort(compareEntries);
251
+ return {
252
+ path,
253
+ entries: rows,
254
+ truncated: overflow > 0
255
+ };
256
+ }
257
+ /** How many symlink target stats run in flight during one level listing. */
258
+ const SYMLINK_PROBE_CONCURRENCY = 32;
259
+ /** Probe each symlink row's target once (bounded concurrency, order-preserving). */
260
+ async function probeSymlinkTargets(rows, concurrency = SYMLINK_PROBE_CONCURRENCY) {
261
+ let next = 0;
262
+ const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
263
+ for (;;) {
264
+ const index = next;
265
+ next += 1;
266
+ if (index >= rows.length) return;
267
+ const row = rows[index];
268
+ if (!row.isSymlink) continue;
269
+ const info = await stat(row.path).catch(() => void 0);
270
+ row.isDir = info !== void 0 ? info.isDirectory() : row.isDir;
271
+ row.broken = info === void 0;
272
+ }
273
+ });
274
+ await Promise.all(workers);
275
+ }
276
+ /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
277
+ function rootLabel(path) {
278
+ const base = basename(path);
279
+ return base !== "" ? base : path;
280
+ }
281
+ /** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
282
+ function parentOf(path) {
283
+ const parent = dirname(path);
284
+ return parent === path ? void 0 : parent;
285
+ }
286
+ /**
287
+ * Normalize a caller-supplied path to an absolute, resolved path or throw
288
+ * fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
289
+ * roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
290
+ * network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
291
+ * stay rejected.
292
+ */
293
+ function requireAbsolute(path) {
294
+ if (!isAbsolute(path)) throw new SidebarError("fs-error", `"${path}" is not an absolute path`, 400);
295
+ return resolve(path);
296
+ }
297
+ /**
298
+ * Whether `target` lies under `base` (or equals it), tolerant of separator
299
+ * style and — on Windows, where the filesystem is case-insensitive — of
300
+ * letter case. The media route uses this instead of a raw `startsWith` so a
301
+ * case-mismatched or mixed-separator path can never be misclassified
302
+ * (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
303
+ * @param platform - filesystem semantics; injectable so both branches are
304
+ * unit-testable on any host.
305
+ */
306
+ function isWithin(base, target, platform = process.platform) {
307
+ const norm = (value) => value.replace(/[\\/]+/g, "/").replace(/\/$/, "");
308
+ const b = norm(base);
309
+ const t = norm(target);
310
+ if (platform === "win32") {
311
+ const lb = b.toLowerCase();
312
+ const lt = t.toLowerCase();
313
+ return lt === lb || lt.startsWith(`${lb}/`);
314
+ }
315
+ return t === b || t.startsWith(`${b}/`);
316
+ }
317
+ /** Message text of an unknown thrown value. */
318
+ function messageOf(error) {
319
+ return error instanceof Error ? error.message : String(error);
320
+ }
321
+ //#endregion
322
+ //#region src/path-security.ts
323
+ /** Filesystem path guards shared by sidebar APIs that access a session workspace. */
324
+ /** Resolve a path and convert filesystem resolution failures to an API error. */
325
+ async function resolveRealPath(path, label) {
326
+ try {
327
+ return await realpath(path);
328
+ } catch (error) {
329
+ throw new SidebarError("fs-error", `cannot resolve ${label} "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
330
+ }
331
+ }
332
+ /** Reject a resolved path whose real filesystem target escapes the workspace. */
333
+ function assertWithinWorkspace(workspace, target) {
334
+ if (!isWithin(workspace, target)) throw new SidebarError("forbidden", `path "${target}" is outside workspace`, 403);
335
+ }
336
+ /**
337
+ * Resolve an existing workspace path through symlinks and enforce containment.
338
+ *
339
+ * @param cwd - Session workspace directory.
340
+ * @param target - Client-supplied absolute path.
341
+ * @returns The canonical absolute path used for the filesystem operation.
342
+ */
343
+ async function ensureWorkspacePath(cwd, target) {
344
+ const absolute = requireAbsolute(target);
345
+ const [realCwd, realTarget] = await Promise.all([resolveRealPath(cwd, "workspace"), resolveRealPath(absolute, "target")]);
346
+ assertWithinWorkspace(realCwd, realTarget);
347
+ return realTarget;
348
+ }
349
+ /**
350
+ * Validate a write destination, including destinations that do not exist yet.
351
+ * Existing targets are resolved to catch symlinks; missing targets are checked
352
+ * against the nearest existing ancestor before the caller creates or renames.
353
+ * The returned path is rebuilt from that canonical ancestor, so an existing
354
+ * symlink is never left in the path passed to the write operation.
355
+ *
356
+ * @param cwd - Session workspace directory.
357
+ * @param target - Client-supplied absolute destination path.
358
+ * @returns A canonical path for an existing target or its nearest existing ancestor.
359
+ */
360
+ async function ensureWorkspaceWritePath(cwd, target) {
361
+ const absolute = requireAbsolute(target);
362
+ const realCwd = await resolveRealPath(cwd, "workspace");
363
+ let existingPath = absolute;
364
+ const missingSegments = [];
365
+ for (;;) try {
366
+ const realTarget = await realpath(existingPath);
367
+ assertWithinWorkspace(realCwd, realTarget);
368
+ return missingSegments.reduce((path, segment) => join(path, segment), realTarget);
369
+ } catch (error) {
370
+ if (error.code !== "ENOENT") {
371
+ if (error instanceof SidebarError) throw error;
372
+ throw new SidebarError("fs-error", `cannot resolve target "${existingPath}": ${error instanceof Error ? error.message : String(error)}`, 400);
373
+ }
374
+ const parent = dirname(existingPath);
375
+ if (parent === existingPath) throw new SidebarError("fs-error", `cannot resolve target "${absolute}"`, 400);
376
+ missingSegments.unshift(basename(existingPath));
377
+ existingPath = parent;
378
+ }
379
+ }
380
+ //#endregion
381
+ //#region src/fs-operations.ts
382
+ /**
383
+ * Workspace-safe file mutations for the sidebar (the upload route today).
384
+ *
385
+ * Every write is confined to the real session workspace: the upload
386
+ * directory is resolved absolute and its target is checked through existing
387
+ * filesystem ancestors, the relative path is sanitized (absolute paths, '.',
388
+ * '..' and empty segments are refused), and the final target must stay inside
389
+ * the workspace after symlink resolution. Bytes stream from the request body
390
+ * to a uniquely named temp sibling
391
+ * and are renamed into place, so a failed, aborted, or oversized upload never
392
+ * leaves a partial file at the target path.
393
+ */
394
+ /**
395
+ * Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp
396
+ * sibling receives the bytes, then is renamed over the target. The parent
397
+ * directory is created on demand (recursive), so folder uploads work before
398
+ * any level exists. The unique temp name keeps concurrent uploads to the same
399
+ * target independent (each writes and renames its own file; the last rename
400
+ * wins) and never blocks later uploads after a crashed process.
401
+ *
402
+ * @throws SidebarError with a wire code for containment, shape, and size
403
+ * failures; the temp file is always removed on failure.
404
+ */
405
+ async function writeWorkspaceUpload(input) {
406
+ const { cwd, dir, relativePath, chunks, limit } = input;
407
+ const base = requireAbsolute(dir);
408
+ await ensureWorkspacePath(cwd, base);
409
+ if (relativePath === "" || relativePath.startsWith("/") || relativePath.startsWith("\\")) throw new SidebarError("bad-request", "relativePath must stay below the upload directory", 400);
410
+ const segments = relativePath.split(/[\\/]/);
411
+ if (segments.some((part) => part === "" || part === "." || part === "..")) throw new SidebarError("bad-request", "relativePath must stay below the upload directory", 400);
412
+ const target = join(base, ...segments);
413
+ const safeTarget = await ensureWorkspaceWritePath(cwd, target);
414
+ const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`);
415
+ await mkdir(dirname(safeTarget), { recursive: true });
416
+ const stream = createWriteStream(tmp, { flags: "wx" });
417
+ const closed = new Promise((resolve) => {
418
+ stream.once("close", () => resolve());
419
+ });
420
+ let size = 0;
421
+ let streamError;
422
+ stream.on("error", (error) => {
423
+ streamError = error;
424
+ });
425
+ try {
426
+ for await (const chunk of chunks) {
427
+ const buffer = Buffer.from(chunk);
428
+ size += buffer.length;
429
+ if (size > limit) throw new SidebarError("too-large", `upload exceeds the ${limit} byte limit`, 413);
430
+ if (!stream.write(buffer)) await once(stream, "drain");
431
+ if (streamError !== void 0) throw streamError;
432
+ }
433
+ await new Promise((resolve, reject) => {
434
+ stream.end((error) => error === void 0 || error === null ? resolve() : reject(error));
435
+ });
436
+ if (streamError !== void 0) throw streamError;
437
+ await rename(tmp, safeTarget);
438
+ return {
439
+ path: target,
440
+ size: (await stat(safeTarget)).size
441
+ };
442
+ } catch (error) {
443
+ stream.destroy();
444
+ await closed.catch(() => {});
445
+ await rm(tmp, { force: true }).catch(() => {});
446
+ throw error;
447
+ }
448
+ }
449
+ //#endregion
450
+ //#region src/fs-search.ts
451
+ /**
452
+ * Recursive file-name search for the editor's merged-mode side panel.
453
+ * Streams the tree with opendir and matches the query as a case-insensitive
454
+ * substring of each entry's NAME (paths stay relative to the search root —
455
+ * the client resolves them against the session cwd). No .gitignore semantics
456
+ * (this is a name lookup, not a code search), but known noise directories
457
+ * (`.git`, `node_modules`, package-manager stores, build caches) are
458
+ * skipped outright and symlink directories are NOT descended (cycle safety).
459
+ *
460
+ * Two performance budgets bound the walk: `maxMatches` (the client renders
461
+ * the flat list) and `maxVisited` (a runaway tree — a home directory root
462
+ * — must not stall the host). Exceeding either stops early with
463
+ * `truncated: true`.
464
+ */
465
+ const DEFAULT_MAX_MATCHES = 200;
466
+ const DEFAULT_MAX_VISITED = 1e5;
467
+ /**
468
+ * Directory names that are never useful filename-search results and would
469
+ * burn the visit budget before the walk reaches project files. Compared
470
+ * case-insensitively so `Node_Modules` / `.GIT` stay skipped on every
471
+ * platform. The directory itself is neither matched nor descended.
472
+ */
473
+ const SEARCH_SKIP_DIRS = /* @__PURE__ */ new Set([
474
+ ".git",
475
+ "node_modules",
476
+ ".pnpm-store",
477
+ ".yarn",
478
+ ".turbo",
479
+ ".turbopack",
480
+ ".next",
481
+ ".nuxt",
482
+ ".output",
483
+ ".cache",
484
+ ".parcel-cache",
485
+ "coverage",
486
+ "dist",
487
+ "build",
488
+ "out",
489
+ ".umi",
490
+ ".umi-production",
491
+ ".dumi"
492
+ ]);
493
+ /**
494
+ * Search `root` recursively for entries whose name contains `query`
495
+ * (case-insensitive).
496
+ * @param root - absolute search root.
497
+ * @param query - the name substring; empty matches nothing.
498
+ * @param opts - budget overrides (tests).
499
+ * @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
500
+ * plus whether a budget cut the walk short. An unreadable level is skipped
501
+ * (permission errors never fail the whole search).
502
+ */
503
+ async function searchFiles(root, query, opts = {}) {
504
+ const needle = query.trim().toLowerCase();
505
+ if (needle === "") return {
506
+ matches: [],
507
+ truncated: false
508
+ };
509
+ const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES;
510
+ const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED;
511
+ const matches = [];
512
+ let visited = 0;
513
+ let truncated = false;
514
+ const walk = async (dir) => {
515
+ if (truncated) return;
516
+ const level = await opendir(dir).catch(() => void 0);
517
+ if (level === void 0) return;
518
+ for await (const dirent of level) {
519
+ visited += 1;
520
+ if (visited > maxVisited) {
521
+ truncated = true;
522
+ return;
523
+ }
524
+ if (dirent.isDirectory() && SEARCH_SKIP_DIRS.has(dirent.name.toLowerCase())) continue;
525
+ if (dirent.name.toLowerCase().includes(needle)) {
526
+ matches.push(join(relative(root, dir), dirent.name));
527
+ if (matches.length >= maxMatches) {
528
+ truncated = true;
529
+ return;
530
+ }
531
+ }
532
+ if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
533
+ await walk(join(dir, dirent.name));
534
+ if (truncated) return;
535
+ }
536
+ }
537
+ };
538
+ await walk(root);
539
+ return {
540
+ matches: matches.sort().map((path) => path.split(sep).join("/")),
541
+ truncated
542
+ };
543
+ }
544
+ /**
545
+ * Decode a route pathname into the session + absolute file path. Rejects
546
+ * a wrong prefix (404), an empty path, malformed percent encoding, and a
547
+ * missing sessionId or file path (400). The caller still must bound the
548
+ * decoded path with the workspace real-path guard — a decoded `..`
549
+ * segment resolves outside the cwd and is refused there.
550
+ */
551
+ function decodeHtmlUrl(pathname) {
552
+ if (!pathname.startsWith("/sidebar/html/")) return {
553
+ ok: false,
554
+ status: 404,
555
+ message: "not an html route"
556
+ };
557
+ const rest = pathname.slice(14);
558
+ if (rest === "") return {
559
+ ok: false,
560
+ status: 400,
561
+ message: "invalid html route path"
562
+ };
563
+ let segments;
564
+ try {
565
+ segments = rest.split("/").map((segment) => decodeURIComponent(segment));
566
+ } catch {
567
+ return {
568
+ ok: false,
569
+ status: 400,
570
+ message: "malformed URL encoding"
571
+ };
572
+ }
573
+ const [sessionId, ...pathSegments] = segments;
574
+ if (sessionId === void 0 || sessionId === "") return {
575
+ ok: false,
576
+ status: 400,
577
+ message: "sessionId and file path are required"
578
+ };
579
+ const unc = pathSegments[0] === "";
580
+ const tail = unc ? pathSegments.slice(1) : pathSegments;
581
+ if (tail.length === 0 || tail.some((segment) => segment === "")) return {
582
+ ok: false,
583
+ status: 400,
584
+ message: "sessionId and file path are required"
585
+ };
586
+ let path;
587
+ if (unc) path = `//${tail.join("/")}`;
588
+ else if (/^[A-Za-z]:$/.test(tail[0] ?? "")) path = tail.join("/");
589
+ else path = `/${tail.join("/")}`;
590
+ return {
591
+ ok: true,
592
+ ref: {
593
+ sessionId,
594
+ path
595
+ }
596
+ };
597
+ }
598
+ //#endregion
599
+ //#region src/browser-probe.ts
600
+ /**
601
+ * Pure helpers for the `browser.probe` route (sidebar browser): the host
602
+ * fetches the response HEADERS of a URL the user is browsing and the client
603
+ * decides whether the target site forbids being embedded (X-Frame-Options /
604
+ * CSP frame-ancestors are exactly the signals the browser enforces when it
605
+ * refuses an iframe load). Kept dependency-free so the parser is
606
+ * unit-testable.
607
+ */
608
+ /**
609
+ * Extract the `frame-ancestors` source list of a Content-Security-Policy
610
+ * header, or undefined when the directive is absent (or empty). The
611
+ * directive is the only one with a source list; sources are space-separated
612
+ * tokens (`'none'`, `'self'`, `*`, or origins).
613
+ */
614
+ function extractFrameAncestors(csp) {
615
+ if (csp === null) return void 0;
616
+ for (const directive of csp.split(";")) {
617
+ const parts = directive.trim().split(/\s+/);
618
+ if (parts[0] === "frame-ancestors") {
619
+ const sources = parts.slice(1).filter((source) => source !== "");
620
+ return sources.length === 0 ? void 0 : sources;
621
+ }
622
+ }
623
+ }
624
+ //#endregion
625
+ //#region src/trust-fence.ts
626
+ function header(headers, name) {
627
+ const value = headers[name];
628
+ return typeof value === "string" ? value : void 0;
629
+ }
630
+ /** Normalized URL of a Host-header authority, or undefined when unparsable. */
631
+ function parseAuthority(authority) {
632
+ try {
633
+ return new URL(`http://${authority}`);
634
+ } catch {
635
+ return;
636
+ }
637
+ }
638
+ /** Whether a normalized URL hostname names the local loopback authority. */
639
+ function isLoopbackHostname(hostname) {
640
+ if (hostname === "localhost" || hostname === "[::1]") return true;
641
+ const parts = hostname.split(".");
642
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
643
+ }
644
+ /** Canonical authority form: hostname, or hostname:port when a port was written. */
645
+ function canonicalAuthority(entry, entryUrl) {
646
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
647
+ return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
648
+ }
649
+ /** Whether the request authority matches a trustedHosts entry (exact or port-less). */
650
+ function isTrustedAuthority(hostUrl, trustedHosts) {
651
+ return trustedHosts.some((entry) => {
652
+ const entryUrl = parseAuthority(entry);
653
+ if (entryUrl === void 0) return false;
654
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
655
+ });
656
+ }
657
+ /**
658
+ * Decide whether one sidebar request may reach the plugin routes.
659
+ * @param request - node HTTP request facts (headers).
660
+ * @param trustedHosts - non-loopback authorities this deployment serves.
661
+ * @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
662
+ */
663
+ function isTrustedApiRequest(request, trustedHosts) {
664
+ const host = header(request.headers, "host");
665
+ if (host === void 0) return false;
666
+ const hostUrl = parseAuthority(host);
667
+ if (hostUrl === void 0) return false;
668
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
669
+ if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
670
+ const origin = header(request.headers, "origin");
671
+ if (origin === void 0) return true;
672
+ try {
673
+ return new URL(origin).hostname === hostUrl.hostname;
674
+ } catch {
675
+ return false;
676
+ }
677
+ }
678
+ //#endregion
679
+ //#region src/bundle-route.ts
680
+ /**
681
+ * Lazy chunk route: serves the client bundle's chunk scripts
682
+ * (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
683
+ * cannot serve arbitrary file names, so the plugin serves its own split
684
+ * bundles (lib/client-<name>.js) here; the client injects the script on
685
+ * first use of the feature that needs it (see src/client/chunk-loader.ts).
686
+ *
687
+ * Caching contract: every response carries `cache-control: no-cache` plus an
688
+ * ETag (content hash, memoized per file by mtime/size) and honors
689
+ * If-None-Match — the browser revalidates each fetch, but a 304 avoids
690
+ * re-downloading multi-MB chunks that did not change (page refresh, HMR
691
+ * re-activation). Same browser-trust fence as every other /sidebar route;
692
+ * only allowlisted chunk names are servable (no path traversal).
693
+ */
694
+ /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
695
+ const CHUNK_NAMES = [
696
+ "terminal",
697
+ "editor",
698
+ "mermaid"
699
+ ];
700
+ /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
701
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
702
+ /** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
703
+ function shortHash(input) {
704
+ return createHash("sha1").update(input).digest("hex").slice(0, 12);
705
+ }
706
+ /** ETag memo: recompute the content hash only when the file's stat changed. */
707
+ const etags = /* @__PURE__ */ new Map();
708
+ /**
709
+ * The chunk file's ETag (quoted hash), or undefined when the file is
710
+ * missing. Hash is recomputed only when mtime/size changed (hashing a
711
+ * multi-MB chunk per request is wasteful).
712
+ */
713
+ async function etagOf(name, chunkDir) {
714
+ const path = join(chunkDir, `client-${name}.js`);
715
+ const key = `${chunkDir}:${name}`;
716
+ try {
717
+ const info = await stat(path);
718
+ const memo = etags.get(key);
719
+ if (memo !== void 0 && memo.mtimeMs === info.mtimeMs && memo.size === info.size) return memo.etag;
720
+ const etag = `"${shortHash(await readFile(path))}"`;
721
+ etags.set(key, {
722
+ mtimeMs: info.mtimeMs,
723
+ size: info.size,
724
+ etag
725
+ });
726
+ return etag;
727
+ } catch {
728
+ return;
729
+ }
730
+ }
731
+ /**
732
+ * Build the /sidebar/bundle route handler. `fence` is the shared browser-
733
+ * trust check every /sidebar route applies; `chunkDir` is the directory the
734
+ * chunk scripts live in (overridable for tests).
735
+ */
736
+ function createBundleRouteHandler(fence, chunkDir = LIB_DIR) {
737
+ return async (req, res) => {
738
+ if (!fence(req)) {
739
+ res.writeHead(403);
740
+ res.end("forbidden");
741
+ return;
742
+ }
743
+ if (req.method !== "GET" && req.method !== "HEAD") {
744
+ res.writeHead(405);
745
+ res.end();
746
+ return;
747
+ }
748
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
749
+ const name = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)?.[1];
750
+ if (name === void 0 || !CHUNK_NAMES.includes(name)) {
751
+ res.writeHead(404);
752
+ res.end("not found");
753
+ return;
754
+ }
755
+ const etag = await etagOf(name, chunkDir);
756
+ if (etag === void 0) {
757
+ res.writeHead(404);
758
+ res.end("not found");
759
+ return;
760
+ }
761
+ if (req.headers["if-none-match"] === etag) {
762
+ res.writeHead(304, {
763
+ "cache-control": "no-cache",
764
+ etag
765
+ });
766
+ res.end();
767
+ return;
768
+ }
769
+ try {
770
+ const body = await readFile(join(chunkDir, `client-${name}.js`));
771
+ res.writeHead(200, {
772
+ "content-type": "text/javascript; charset=utf-8",
773
+ "cache-control": "no-cache",
774
+ etag
775
+ });
776
+ res.end(body);
777
+ } catch {
778
+ res.writeHead(404);
779
+ res.end("not found");
780
+ }
781
+ };
782
+ }
783
+ /** Register the /sidebar/bundle route (disposed with the fiber). */
784
+ function registerBundleRoute(ctx, fence) {
785
+ return ctx.webServer.register({
786
+ kind: "prefix",
787
+ path: "/sidebar/bundle",
788
+ handler: createBundleRouteHandler(fence)
789
+ });
790
+ }
791
+ //#endregion
792
+ //#region src/open-external.ts
793
+ /**
794
+ * External open actions for the file tree's "open with" menu: hand a path to
795
+ * the OS file manager (reveal/select) or launch a URL scheme's registered
796
+ * handler (vscode://, cursor://, zed://, custom schemes).
797
+ *
798
+ * The client runs in a browser / DSH Desktop renderer where a raw `vscode://`
799
+ * navigation is unreliable, so both actions fan out through this host route
800
+ * and spawn the platform opener with an argv array (no shell interpolation).
801
+ * The command builders are pure — the platform is injectable — so every
802
+ * per-platform branch is unit-testable without spawning anything.
803
+ */
804
+ /** Reveal/select a path in the OS file manager. On Linux there is no common
805
+ * select protocol — the containing directory is opened instead (KISS). */
806
+ function revealCommand(path, platform = process.platform) {
807
+ switch (platform) {
808
+ case "darwin": return {
809
+ command: "open",
810
+ args: ["-R", path]
811
+ };
812
+ case "win32": return {
813
+ command: "explorer.exe",
814
+ args: ["/select,", path]
815
+ };
816
+ default: return {
817
+ command: "xdg-open",
818
+ args: [parentOf(path) ?? path]
819
+ };
820
+ }
821
+ }
822
+ /** Hand a custom-scheme URL to the OS protocol handler. */
823
+ function urlCommand(url, platform = process.platform) {
824
+ switch (platform) {
825
+ case "darwin": return {
826
+ command: "open",
827
+ args: [url]
828
+ };
829
+ case "win32": return {
830
+ command: "rundll32.exe",
831
+ args: ["url.dll,FileProtocolHandler", url]
832
+ };
833
+ default: return {
834
+ command: "xdg-open",
835
+ args: [url]
836
+ };
837
+ }
838
+ }
839
+ /** Validate a URL-scheme open target: a parseable custom-scheme URL (never
840
+ * http/https — those would only dump the URL into a browser tab). */
841
+ function validateExternalUrl(raw) {
842
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) throw new SidebarError("bad-request", "url must be a custom-scheme URL");
843
+ let url;
844
+ try {
845
+ url = new URL(raw);
846
+ } catch {
847
+ throw new SidebarError("bad-request", "invalid url");
848
+ }
849
+ if (url.protocol === "http:" || url.protocol === "https:") throw new SidebarError("bad-request", "only custom-scheme urls can be opened externally");
850
+ return raw;
851
+ }
852
+ /**
853
+ * Launch one external open action and return immediately (detached, no
854
+ * stdio). Spawn failures are reported through the child's 'error' event —
855
+ * by then the route already returned, so the event is swallowed (the OS
856
+ * dialog about a missing handler is the user-visible outcome either way).
857
+ */
858
+ function launchExternal(action, value) {
859
+ const platform = process.platform;
860
+ const spec = action === "reveal" ? revealCommand(requireAbsolute(value), platform) : urlCommand(validateExternalUrl(value), platform);
861
+ const child = spawn(spec.command, spec.args, {
862
+ detached: true,
863
+ stdio: "ignore"
864
+ });
865
+ child.on("error", () => {});
866
+ child.unref();
867
+ return { started: true };
868
+ }
869
+ //#endregion
870
+ //#region src/git.ts
871
+ /**
872
+ * Git operations for the sidebar source-control panel. Everything goes
873
+ * through the system `git` binary spawned per request (no library, no state),
874
+ * with porcelain-parseable output formats (`-z` NUL framing, unit separators)
875
+ * so parsing never depends on locale or color config. All commands run with
876
+ * `-C <cwd>` on the session's working directory and `--no-pager` /
877
+ * `-c color.ui=false` so output stays machine-readable.
878
+ *
879
+ * Commits use the user's git global identity untouched (never sets
880
+ * user.name/user.email).
881
+ */
882
+ /** One git failure (stderr text as the message). */
883
+ var GitCommandError = class extends Error {
884
+ code;
885
+ command;
886
+ constructor(message, code = "git-error", command) {
887
+ super(message);
888
+ this.code = code;
889
+ this.command = command;
890
+ }
891
+ };
892
+ /** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
893
+ function parsePorcelainZ(output) {
894
+ const tokens = output.split("\0");
895
+ const entries = [];
896
+ let index = 0;
897
+ while (index < tokens.length) {
898
+ const token = tokens[index];
899
+ index += 1;
900
+ if (token === "") continue;
901
+ const xy = token.slice(0, 2);
902
+ const rest = token.slice(3);
903
+ entries.push({
904
+ path: rest,
905
+ xy
906
+ });
907
+ if ((xy[0] === "R" || xy[0] === "C") && tokens[index] !== void 0 && tokens[index] !== "") index += 1;
908
+ }
909
+ return entries;
910
+ }
911
+ /** Parse `git worktree list --porcelain` records. Production requests use
912
+ * `-z` so even newlines and non-ASCII bytes in checkout paths stay lossless;
913
+ * newline framing remains accepted for small fixtures and older Git output. */
914
+ function parseWorktreeList(output) {
915
+ const rows = [];
916
+ let path;
917
+ let branch = "HEAD";
918
+ let locked = false;
919
+ let prunable = false;
920
+ const flush = () => {
921
+ if (path !== void 0) rows.push({
922
+ path,
923
+ branch,
924
+ locked,
925
+ prunable
926
+ });
927
+ path = void 0;
928
+ branch = "HEAD";
929
+ locked = false;
930
+ prunable = false;
931
+ };
932
+ const sep = output.includes("\0") ? "\0" : "\n";
933
+ const framed = output.endsWith(sep) ? output : `${output}${sep}`;
934
+ for (const line of framed.split(sep)) if (line === "") flush();
935
+ else if (line.startsWith("worktree ")) path = line.slice(9);
936
+ else if (line.startsWith("branch refs/heads/")) branch = line.slice(18);
937
+ else if (line === "locked" || line.startsWith("locked ")) locked = true;
938
+ else if (line === "prunable" || line.startsWith("prunable ")) prunable = true;
939
+ return rows;
940
+ }
941
+ /** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
942
+ function parseLogLines(output) {
943
+ const rows = [];
944
+ for (const line of output.split("\n")) {
945
+ if (line === "") continue;
946
+ const [hash, subject, author, date, hashFull, refs] = line.split("");
947
+ if (hash === void 0 || subject === void 0) continue;
948
+ rows.push({
949
+ hash,
950
+ subject,
951
+ author: author ?? "",
952
+ date: date ?? "",
953
+ hashFull: hashFull ?? hash,
954
+ refs: refs ?? ""
955
+ });
956
+ }
957
+ return rows;
958
+ }
959
+ /** Run one git command; resolves with stdout, rejects with GitCommandError. */
960
+ function runGit(cwd, args, timeoutMs = 3e4) {
961
+ const full = [
962
+ "-C",
963
+ cwd,
964
+ "--no-pager",
965
+ "-c",
966
+ "color.ui=false",
967
+ ...args
968
+ ];
969
+ return new Promise((resolvePromise, reject) => {
970
+ const child = spawn("git", full, {
971
+ stdio: [
972
+ "ignore",
973
+ "pipe",
974
+ "pipe"
975
+ ],
976
+ windowsHide: true,
977
+ env: {
978
+ ...process.env,
979
+ GIT_OPTIONAL_LOCKS: "0"
980
+ }
981
+ });
982
+ let stdout = "";
983
+ let stderr = "";
984
+ const timer = setTimeout(() => {
985
+ child.kill("SIGKILL");
986
+ reject(new GitCommandError(`git ${args[0] ?? ""} timed out after ${timeoutMs}ms`, "git-error", args.join(" ")));
987
+ }, timeoutMs);
988
+ child.stdout.on("data", (chunk) => {
989
+ stdout += chunk.toString("utf8");
990
+ });
991
+ child.stderr.on("data", (chunk) => {
992
+ stderr += chunk.toString("utf8");
993
+ });
994
+ child.on("error", (error) => {
995
+ clearTimeout(timer);
996
+ reject(new GitCommandError(`cannot run git: ${error.message}`, "git-error", args.join(" ")));
997
+ });
998
+ child.on("close", (code) => {
999
+ clearTimeout(timer);
1000
+ if (code === 0) resolvePromise(stdout);
1001
+ else reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, "git-error", args.join(" ")));
1002
+ });
1003
+ });
1004
+ }
1005
+ /** Cap on child directories probed by the workspace-container fallback scan.
1006
+ * A home-directory cwd can hold hundreds of visible folders (Library, iCloud
1007
+ * mounts…); probing them all serially is what froze the panel in #369. */
1008
+ const DISCOVERY_LIMIT = 200;
1009
+ /** Per-probe and direct-discovery budget. `rev-parse` is millisecond-scale on
1010
+ * a healthy checkout; a probe that needs longer is a stalled mount and is
1011
+ * better abandoned than waited on. */
1012
+ const DISCOVERY_TIMEOUT_MS = 5e3;
1013
+ /** Discovery results are cheap to recompute but expensive to storm: the panel
1014
+ * polls every 2s and each poll fans out into several git.* calls that all
1015
+ * resolve the same roots. A short TTL keeps fan-out at one scan per cwd. */
1016
+ const DISCOVERY_CACHE_TTL_MS = 6e4;
1017
+ const repoRootsCache = /* @__PURE__ */ new Map();
1018
+ const repoRootsInFlight = /* @__PURE__ */ new Map();
1019
+ /** Whether the directory is inside a git work tree (exit-0 `git rev-parse`).
1020
+ * Probe timeout is short: a cwd on a stalled mount must not hold the panel
1021
+ * hostage for the full command budget (issue #369). */
1022
+ async function isGitRepo(cwd) {
1023
+ try {
1024
+ return (await runGit(cwd, ["rev-parse", "--is-inside-work-tree"], DISCOVERY_TIMEOUT_MS)).trim() === "true";
1025
+ } catch {
1026
+ return false;
1027
+ }
1028
+ }
1029
+ /** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
1030
+ async function directRepoRoot(cwd) {
1031
+ return (await runGit(cwd, ["rev-parse", "--show-toplevel"], DISCOVERY_TIMEOUT_MS)).trim();
1032
+ }
1033
+ /** Discover the current repository or direct child repositories. Results are
1034
+ * cached per cwd and concurrent callers share one in-flight scan, so opening
1035
+ * the panel (three parallel git.* requests) costs a single discovery pass. */
1036
+ function repoRoots(cwd) {
1037
+ const cached = repoRootsCache.get(cwd);
1038
+ if (cached !== void 0 && cached.expires > Date.now()) return Promise.resolve(cached.roots);
1039
+ const pending = repoRootsInFlight.get(cwd);
1040
+ if (pending !== void 0) return pending;
1041
+ const promise = discoverRepoRoots(cwd).then((roots) => {
1042
+ repoRootsCache.set(cwd, {
1043
+ roots,
1044
+ expires: Date.now() + DISCOVERY_CACHE_TTL_MS
1045
+ });
1046
+ repoRootsInFlight.delete(cwd);
1047
+ return roots;
1048
+ }, (error) => {
1049
+ repoRootsInFlight.delete(cwd);
1050
+ throw error;
1051
+ });
1052
+ repoRootsInFlight.set(cwd, promise);
1053
+ return promise;
1054
+ }
1055
+ async function discoverRepoRoots(cwd) {
1056
+ try {
1057
+ return [await directRepoRoot(cwd)];
1058
+ } catch {
1059
+ const entries = await readdir(cwd, { withFileTypes: true }).catch(() => []);
1060
+ const roots = [];
1061
+ for (const entry of entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules").sort((left, right) => left.name.localeCompare(right.name)).slice(0, DISCOVERY_LIMIT)) try {
1062
+ const root = await directRepoRoot(join(cwd, entry.name));
1063
+ if (!roots.some((existing) => pathIdentity(existing) === pathIdentity(root))) roots.push(root);
1064
+ } catch {}
1065
+ return roots;
1066
+ }
1067
+ }
1068
+ /** Resolve the selected repository, defaulting to the first discovered root. */
1069
+ async function repoRoot(cwd, selected) {
1070
+ const roots = await repoRoots(cwd);
1071
+ if (roots.length === 0) throw new GitCommandError("not a git repository", "not-repo", "rev-parse");
1072
+ if (selected !== void 0) {
1073
+ const identity = pathIdentity(selected);
1074
+ const match = roots.find((root) => pathIdentity(root) === identity);
1075
+ if (match !== void 0) return match;
1076
+ }
1077
+ return roots[0];
1078
+ }
1079
+ /** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
1080
+ async function currentBranch(cwd) {
1081
+ return (await runGit(cwd, [
1082
+ "rev-parse",
1083
+ "--abbrev-ref",
1084
+ "HEAD"
1085
+ ])).trim();
1086
+ }
1087
+ /** Upper bound on status rows shipped to the client. Beyond this the result
1088
+ * is truncated (with `truncated: true`) so a pathological untracked set —
1089
+ * e.g. the working tree discovered under a home-directory cwd — cannot
1090
+ * freeze the browser main thread on JSON parse or list render (#369). */
1091
+ const GIT_STATUS_LIMIT = 2e3;
1092
+ /**
1093
+ * Working-tree status (untracked included). `--untracked-files=all` lists
1094
+ * the contents of new directories as individual entries, while preserving
1095
+ * repository discovery and explicit repository selection for workspace roots.
1096
+ */
1097
+ async function status(cwd, selected) {
1098
+ const repositories = await repoRoots(cwd);
1099
+ if (repositories.length === 0) return {
1100
+ isRepo: false,
1101
+ entries: [],
1102
+ repositories: []
1103
+ };
1104
+ const root = await repoRoot(cwd, selected);
1105
+ const [branch, raw] = await Promise.all([currentBranch(root).catch(() => "HEAD"), runGit(root, [
1106
+ "status",
1107
+ "--porcelain=v1",
1108
+ "-z",
1109
+ "--untracked-files=all"
1110
+ ])]);
1111
+ const parsed = parsePorcelainZ(raw);
1112
+ const truncated = parsed.length > GIT_STATUS_LIMIT;
1113
+ return {
1114
+ isRepo: true,
1115
+ branch,
1116
+ entries: truncated ? parsed.slice(0, GIT_STATUS_LIMIT) : parsed,
1117
+ truncated,
1118
+ root,
1119
+ repositories
1120
+ };
1121
+ }
1122
+ /** Platform-aware identity used only for comparing absolute checkout roots. */
1123
+ function pathIdentity(path) {
1124
+ const absolute = resolve(path).replace(/[\\/]+$/, "");
1125
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
1126
+ }
1127
+ /** Raw usable checkout records, shared by inventory and target validation.
1128
+ * Prunable records point at missing paths and are deliberately excluded from
1129
+ * both the selector and the command-target allowlist. */
1130
+ async function listedWorktrees(cwd) {
1131
+ return parseWorktreeList(await runGit(cwd, [
1132
+ "worktree",
1133
+ "list",
1134
+ "--porcelain",
1135
+ "-z"
1136
+ ])).filter((entry) => !entry.prunable);
1137
+ }
1138
+ /** All linked checkouts of the repository containing `cwd`, enriched with a
1139
+ * live change count. The current checkout is first so a single-worktree repo
1140
+ * preserves the old UI ordering. */
1141
+ async function worktrees(cwd) {
1142
+ if (!await isGitRepo(cwd)) return [];
1143
+ const currentRoot = await repoRoot(cwd);
1144
+ const listed = await listedWorktrees(cwd);
1145
+ return (await Promise.all(listed.map(async (entry) => ({
1146
+ path: entry.path,
1147
+ branch: entry.branch,
1148
+ current: pathIdentity(entry.path) === pathIdentity(currentRoot),
1149
+ changes: await status(entry.path).then((result) => result.entries.length, () => 0)
1150
+ })))).sort((left, right) => Number(right.current) - Number(left.current));
1151
+ }
1152
+ /** Resolve an optional client-selected linked checkout. A caller may never use
1153
+ * this seam to point Git operations at an unrelated repository: the target
1154
+ * must occur in the authoritative session repository's worktree list. */
1155
+ async function resolveWorktree(cwd, requested) {
1156
+ if (requested === void 0 || requested === "") return cwd;
1157
+ const identity = pathIdentity(requested);
1158
+ const match = (await listedWorktrees(cwd)).find((entry) => pathIdentity(entry.path) === identity);
1159
+ if (match === void 0) throw new GitCommandError(`unknown linked worktree: ${requested}`, "git-worktree", "worktree list");
1160
+ return match.path;
1161
+ }
1162
+ /** Diff text of the worktree (unstaged) or the index (staged). */
1163
+ async function diff(cwd, path, staged, selected) {
1164
+ const root = await repoRoot(cwd, selected);
1165
+ const args = [
1166
+ "diff",
1167
+ "--no-ext-diff",
1168
+ "--no-color",
1169
+ "-U3"
1170
+ ];
1171
+ if (staged) args.push("--cached");
1172
+ if (path !== void 0) args.push("--", path);
1173
+ return runGit(root, args);
1174
+ }
1175
+ /** Stage paths (all when path is undefined). */
1176
+ async function stage(cwd, path, selected) {
1177
+ await runGit(await repoRoot(cwd, selected), [
1178
+ "add",
1179
+ "-A",
1180
+ ...path !== void 0 ? ["--", path] : []
1181
+ ]);
1182
+ }
1183
+ /** Unstage paths (all when path is undefined). */
1184
+ async function unstage(cwd, path, selected) {
1185
+ await runGit(await repoRoot(cwd, selected), [
1186
+ "reset",
1187
+ "-q",
1188
+ ...path !== void 0 ? ["--", path] : []
1189
+ ]);
1190
+ }
1191
+ /** Commit the staged changes with a message (global identity untouched). */
1192
+ async function commit(cwd, message, selected) {
1193
+ await runGit(await repoRoot(cwd, selected), [
1194
+ "commit",
1195
+ "-m",
1196
+ message
1197
+ ]);
1198
+ }
1199
+ /** Branch names (current first). */
1200
+ async function branches(cwd, selected) {
1201
+ const root = await repoRoot(cwd, selected);
1202
+ const [current, raw] = await Promise.all([currentBranch(root).catch(() => "HEAD"), runGit(root, [
1203
+ "for-each-ref",
1204
+ "--format=%(refname:short)",
1205
+ "refs/heads"
1206
+ ])]);
1207
+ const names = raw.split("\n").filter((line) => line !== "");
1208
+ return {
1209
+ current,
1210
+ names: names.includes(current) ? names : [current, ...names]
1211
+ };
1212
+ }
1213
+ /** Switch to an existing branch. */
1214
+ async function checkout(cwd, branch, selected) {
1215
+ await runGit(await repoRoot(cwd, selected), ["checkout", branch]);
1216
+ }
1217
+ /** Recent commit history (newest first), lazily pageable via skip/count. */
1218
+ async function log(cwd, count = 30, skip = 0, selected) {
1219
+ return parseLogLines(await runGit(await repoRoot(cwd, selected), [
1220
+ "log",
1221
+ "-n",
1222
+ String(count),
1223
+ "--skip",
1224
+ String(skip),
1225
+ "--decorate=short",
1226
+ "--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D"
1227
+ ]));
1228
+ }
1229
+ /**
1230
+ * Content of a file at a revision (`git show <rev>:<path>`), or null when the
1231
+ * revision has no such path (a new/untracked file has no HEAD side).
1232
+ */
1233
+ async function show(cwd, rev, path, selected) {
1234
+ try {
1235
+ return await runGit(await repoRoot(cwd, selected), ["show", `${rev}:${path}`]);
1236
+ } catch {
1237
+ return null;
1238
+ }
1239
+ }
1240
+ /** Full patch text of one commit (`git show` with the commit header suppressed).
1241
+ * Merge commits show their diff against the first parent (`-m --first-parent`
1242
+ * is a no-op for regular commits), so a history click always has content. */
1243
+ async function commitDiff(cwd, hash, selected) {
1244
+ return runGit(await repoRoot(cwd, selected), [
1245
+ "show",
1246
+ "--no-ext-diff",
1247
+ "--no-color",
1248
+ "--format=",
1249
+ "-m",
1250
+ "--first-parent",
1251
+ hash
1252
+ ]);
1253
+ }
1254
+ /** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
1255
+ async function discard(cwd, path, selected) {
1256
+ await runGit(await repoRoot(cwd, selected), [
1257
+ "checkout",
1258
+ "--",
1259
+ path
1260
+ ]);
1261
+ }
1262
+ /** Revert one commit onto the current branch with an auto-generated message. */
1263
+ async function revert(cwd, hash, selected) {
1264
+ await runGit(await repoRoot(cwd, selected), [
1265
+ "revert",
1266
+ "--no-edit",
1267
+ hash
1268
+ ]);
1269
+ }
1270
+ /** Cherry-pick one commit onto the current branch. */
1271
+ async function cherryPick(cwd, hash, selected) {
1272
+ await runGit(await repoRoot(cwd, selected), ["cherry-pick", hash]);
1273
+ }
1274
+ //#endregion
1275
+ //#region src/pty-deps.ts
1276
+ /**
1277
+ * node-pty dependency loading for the host half (issue #140, plugin side).
1278
+ *
1279
+ * The terminal surfaces (UI tabs + model-facing terminal_* tools) need
1280
+ * node-pty, but the package must NEVER be imported statically at module
1281
+ * top level: a missing or broken install (pnpm 11's strict-dep-builds
1282
+ * skipping node-pty's install script, a pruned store entry, a failed
1283
+ * prebuilt-binary download…) would then fail the plugin module load and —
1284
+ * because a loader entry apply failure aborts the boot — take the whole
1285
+ * `dsh web` server down with it.
1286
+ *
1287
+ * Instead the host half loads node-pty lazily (synchronously, via
1288
+ * createRequire — the same resolution `ensureSpawnHelper` already uses in
1289
+ * production). When the load fails the plugin stays mounted in a degraded
1290
+ * state: the terminal tab shows a friendly error carrying a pasteable
1291
+ * repair command (see scripts/install.sh / install.ps1 `--repair`), and the
1292
+ * agent terminal tools are simply not registered.
1293
+ *
1294
+ * Version contract: the plugin must stay in sync with DSH core —
1295
+ * `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
1296
+ * its `dependencies`. Both sides then resolve the SAME pnpm store entry
1297
+ * (same range, same integrity → one native binding, no drift). Do NOT
1298
+ * switch to a fork (e.g. @lydell/node-pty) or a different range without
1299
+ * re-checking the core declaration.
1300
+ */
1301
+ /**
1302
+ * The node-pty version range this plugin ships. MUST stay identical to the
1303
+ * range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
1304
+ * range keeps pnpm resolving both to one physical package.
1305
+ */
1306
+ const DSH_NODE_PTY_RANGE = "^1.1.0";
1307
+ /**
1308
+ * The WebSocket close-code-1011 reason the host sends when node-pty is
1309
+ * unavailable. The client recognizes this exact marker and fetches the full
1310
+ * repair details from `/sidebar/api/terminal.deps` (a WS close reason is
1311
+ * capped at 123 bytes, so the command itself cannot ride the close frame).
1312
+ */
1313
+ const PTY_DEPS_MISSING = "pty-deps-missing";
1314
+ const defaultRequire = createRequire(import.meta.url);
1315
+ let cached;
1316
+ /**
1317
+ * Load node-pty once (synchronously) and cache the outcome. Returns null
1318
+ * when the package or its native binding cannot be loaded; the cause stays
1319
+ * queryable through {@link nodePtyLoadCause}. Never throws.
1320
+ */
1321
+ function loadNodePty(requireImpl = defaultRequire) {
1322
+ if (cached === void 0) try {
1323
+ cached = {
1324
+ ok: true,
1325
+ module: requireImpl("node-pty")
1326
+ };
1327
+ } catch (cause) {
1328
+ cached = {
1329
+ ok: false,
1330
+ cause
1331
+ };
1332
+ }
1333
+ return cached.ok ? cached.module : null;
1334
+ }
1335
+ /** The recorded load failure (undefined when the load succeeded or never ran). */
1336
+ function nodePtyLoadCause() {
1337
+ return cached !== void 0 && !cached.ok ? cached.cause : void 0;
1338
+ }
1339
+ /** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
1340
+ function loadRequiredNodePty() {
1341
+ const module = loadNodePty();
1342
+ if (module === null) {
1343
+ const cause = describeCause(nodePtyLoadCause());
1344
+ throw new SidebarError("pty-deps-missing", `node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`, 503);
1345
+ }
1346
+ return module;
1347
+ }
1348
+ /** Resolve a directory to its physical location (symlinked/link: installs). */
1349
+ function realDir(file) {
1350
+ try {
1351
+ return dirname(realpathSync(file));
1352
+ } catch {
1353
+ return dirname(file);
1354
+ }
1355
+ }
1356
+ /** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
1357
+ function walkUp(dir, isRoot) {
1358
+ let current = dir;
1359
+ for (let depth = 0; depth < 16; depth += 1) {
1360
+ if (isRoot(current)) return current;
1361
+ const parent = dirname(current);
1362
+ if (parent === current) break;
1363
+ current = parent;
1364
+ }
1365
+ return null;
1366
+ }
1367
+ /** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
1368
+ function isProfileRoot(dir) {
1369
+ return existsSync(join(dir, "package.json")) && existsSync(join(dir, "pnpm-workspace.yaml"));
1370
+ }
1371
+ /**
1372
+ * Detect the DSH profile directory this plugin is installed into: the
1373
+ * nearest ancestor of the plugin module that carries both `package.json`
1374
+ * and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
1375
+ * profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
1376
+ * standard web profile), then null.
1377
+ */
1378
+ function findProfileDir(fromFile = fileURLToPath(import.meta.url)) {
1379
+ const detected = walkUp(realDir(fromFile), isProfileRoot);
1380
+ if (detected !== null) return detected;
1381
+ const home = process.env.DSH_HOME !== void 0 && process.env.DSH_HOME.trim() !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
1382
+ const web = join(home, "profiles", "web");
1383
+ return isProfileRoot(web) ? realpathSync(web) : null;
1384
+ }
1385
+ /** Whether `dir`'s package.json declares this plugin's name. */
1386
+ function isPluginRoot(dir) {
1387
+ const file = join(dir, "package.json");
1388
+ if (!existsSync(file)) return false;
1389
+ try {
1390
+ return JSON.parse(readFileSync(file, "utf8")).name === "dsh-coding-sidebar";
1391
+ } catch {
1392
+ return false;
1393
+ }
1394
+ }
1395
+ /** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
1396
+ function findPluginRoot(fromFile = fileURLToPath(import.meta.url)) {
1397
+ return walkUp(realDir(fromFile), isPluginRoot);
1398
+ }
1399
+ /**
1400
+ * The pasteable repair command for a broken node-pty install: rerun the
1401
+ * plugin's own installer in `--repair` mode (idempotent: it re-writes the
1402
+ * profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
1403
+ * dependency). Falls back to DSH's plugin command when the scripts are not
1404
+ * shipped (exotic layouts).
1405
+ */
1406
+ function buildRepairCommand(options) {
1407
+ const { pluginRoot, profileDir } = options;
1408
+ const platform = options.platform ?? process.platform;
1409
+ const profileName = profileDir !== null ? basename(profileDir) : null;
1410
+ const profileArg = profileName !== null ? platform === "win32" ? ` -Profile "${profileName}"` : ` --profile "${profileName}"` : "";
1411
+ if (pluginRoot !== null) {
1412
+ if (platform === "win32") {
1413
+ const script = join(pluginRoot, "scripts", "install.ps1");
1414
+ if (existsSync(script)) return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` };
1415
+ } else {
1416
+ const script = join(pluginRoot, "scripts", "install.sh");
1417
+ if (existsSync(script)) return { command: `bash "${script}" --repair${profileArg}` };
1418
+ }
1419
+ }
1420
+ return {
1421
+ command: `dsh plugin --profile "${profileName ?? "web"}" install`,
1422
+ note: "If pnpm 11 blocked node-pty's build script, ensure `allowBuilds: node-pty: true` in the profile's pnpm-workspace.yaml (the plugin's scripts/install.sh / install.ps1 --repair does this automatically)."
1423
+ };
1424
+ }
1425
+ /** One-line human description of the recorded load cause. */
1426
+ function describeCause(cause) {
1427
+ if (cause instanceof Error) return cause.message;
1428
+ return String(cause);
1429
+ }
1430
+ /** Current node-pty dependency status (loaded vs degraded + repair info). */
1431
+ function depsStatus(options = {}) {
1432
+ if (loadNodePty() !== null) return { ok: true };
1433
+ const pluginRoot = findPluginRoot(options.fromFile);
1434
+ const profileDir = findProfileDir(options.fromFile);
1435
+ const { command, note } = buildRepairCommand({
1436
+ pluginRoot,
1437
+ profileDir
1438
+ });
1439
+ return {
1440
+ ok: false,
1441
+ cause: describeCause(nodePtyLoadCause()),
1442
+ command,
1443
+ profile: profileDir !== null ? basename(profileDir) : null,
1444
+ ...note !== void 0 ? { note } : {}
1445
+ };
1446
+ }
1447
+ //#endregion
1448
+ //#region src/pty-manager.ts
1449
+ /**
1450
+ * PTY session table for the sidebar terminals. One node-pty process per
1451
+ * `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
1452
+ * (page refresh, tab switch) and reconnect to the same process by key.
1453
+ * Output is mirrored into a bounded transcript ring (capped bytes) so a new
1454
+ * connection replays history before live data. Sessions die only when the
1455
+ * tab is closed or the plugin tears down.
1456
+ */
1457
+ /** Per-terminal transcript bound (bytes kept for replay). */
1458
+ const TRANSCRIPT_LIMIT$1 = 1 << 20;
1459
+ /**
1460
+ * Restore the executable bit pnpm strips from node-pty's prebuilt
1461
+ * spawn-helper (the macOS helper that forks and sets up the pty). Without it
1462
+ * every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
1463
+ * @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
1464
+ * plugin activation so link-installed deployments get the fix too.
1465
+ */
1466
+ function ensureSpawnHelper() {
1467
+ if (process.platform === "win32") return;
1468
+ try {
1469
+ const entry = createRequire(import.meta.url).resolve("node-pty");
1470
+ const packageRoot = dirname(dirname(entry));
1471
+ const candidates = [join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), join(packageRoot, "build", "Release", "spawn-helper")];
1472
+ for (const helper of candidates) if (existsSync(helper)) chmodSync(helper, 493);
1473
+ } catch {}
1474
+ }
1475
+ /**
1476
+ * The terminal registry. `maxPerSession` bounds concurrent processes per
1477
+ * conversation (the client caps tabs at the same number).
1478
+ *
1479
+ * Lifecycle of a UI-tab pty when its WebSocket drops:
1480
+ * - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a
1481
+ * 0-ms close (quota released immediately).
1482
+ * - **Park frame** (`{type:'park'}`): the user switched to another
1483
+ * conversation; the tab is still open in its session's persisted state but
1484
+ * its view unmounted → mark the pty as parked (no auto-close countdown).
1485
+ * The pty stays alive until the user switches back (a reconnecting view
1486
+ * calls `open()` which clears the parked state) or the tab is later closed
1487
+ * (a `{type:'close'}` frame from a fresh connection). Without `park`, a
1488
+ * bare socket drop would start the reconnect-grace countdown and kill the
1489
+ * shell after `reconnectGraceMs` — wrong for a session switch, where the
1490
+ * user is still actively using the app, just in another conversation.
1491
+ * - **Bare socket drop** (no frame): page refresh, crash, plugin teardown →
1492
+ * schedule a close after `reconnectGraceMs` so a quick reconnect reattaches
1493
+ * the same shell.
1494
+ */
1495
+ var PtyManager = class {
1496
+ shell;
1497
+ maxPerSession;
1498
+ shellArgs;
1499
+ nodePty;
1500
+ sessions = /* @__PURE__ */ new Map();
1501
+ pendingCloses = /* @__PURE__ */ new Map();
1502
+ /** Tabs whose view unmounted because the user switched conversations — the
1503
+ * tab is still open in its session's state, so the pty must NOT enter the
1504
+ * reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting
1505
+ * view's `open()` cancels it) or by `scheduleClose` (an explicit close
1506
+ * frame still kills a parked pty). */
1507
+ parked = /* @__PURE__ */ new Set();
1508
+ constructor(shell, maxPerSession, shellArgs = [], nodePty = loadRequiredNodePty()) {
1509
+ this.shell = shell;
1510
+ this.maxPerSession = maxPerSession;
1511
+ this.shellArgs = shellArgs;
1512
+ this.nodePty = nodePty;
1513
+ }
1514
+ /** All live terminal keys of one session. */
1515
+ keysOf(sessionId) {
1516
+ const keys = [];
1517
+ for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) keys.push(handle.key);
1518
+ return keys;
1519
+ }
1520
+ /**
1521
+ * Open (or reuse) the terminal for a session/tab key. A handle whose
1522
+ * process already exited is replaced with a fresh spawn (reconnecting a
1523
+ * dead terminal must yield a live shell, not an input sink), and so is a
1524
+ * live handle whose spawn cwd differs from the now-authoritative one (the
1525
+ * first connect of a page load can arrive before the session hydrates, so
1526
+ * it fell back to the process cwd — reconnecting with the real cwd must
1527
+ * restart the shell in the right directory). Reopening also cancels any
1528
+ * pending scheduled close (a reconnect within the grace window keeps the
1529
+ * process alive).
1530
+ * @param sessionId - conversation id.
1531
+ * @param tabId - client tab id.
1532
+ * @param cwd - initial working directory (the session's cwd).
1533
+ * @param cols - initial terminal width.
1534
+ * @param rows - initial terminal height.
1535
+ * @returns the live handle.
1536
+ * @throws {SidebarError} pty-error when the per-session cap is reached.
1537
+ */
1538
+ open(sessionId, tabId, cwd, cols, rows, shell, shellArgs) {
1539
+ const key = `${sessionId}:${tabId}`;
1540
+ this.cancelClose(key);
1541
+ const existing = this.sessions.get(key);
1542
+ if (existing !== void 0 && !existing.exited && existing.cwd === cwd) return existing;
1543
+ if (existing !== void 0) this.close(key);
1544
+ for (const [candidate, handle] of [...this.sessions]) if (handle.sessionId === sessionId && handle.exited) this.close(candidate);
1545
+ if (this.keysOf(sessionId).length >= this.maxPerSession) throw new SidebarError("pty-error", `terminal limit reached (${this.maxPerSession}) for this session`, 400);
1546
+ const handle = {
1547
+ key,
1548
+ sessionId,
1549
+ tabId,
1550
+ cwd,
1551
+ pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
1552
+ name: "xterm-256color",
1553
+ cols: Math.max(2, Math.floor(cols)),
1554
+ rows: Math.max(2, Math.floor(rows)),
1555
+ cwd,
1556
+ env: { ...process.env }
1557
+ }),
1558
+ transcript: "",
1559
+ exited: false
1560
+ };
1561
+ handle.pty.onData((data) => {
1562
+ handle.transcript += data;
1563
+ if (handle.transcript.length > TRANSCRIPT_LIMIT$1) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT$1);
1564
+ });
1565
+ handle.pty.onExit(({ exitCode }) => {
1566
+ handle.exited = true;
1567
+ handle.exitCode = exitCode;
1568
+ });
1569
+ this.sessions.set(key, handle);
1570
+ return handle;
1571
+ }
1572
+ /**
1573
+ * Schedule the terminal's destruction after `delayMs`. A tab close sends
1574
+ * delay 0 (release the quota immediately); a bare socket drop (refresh,
1575
+ * crash) uses the grace period so a quick reconnect keeps the process.
1576
+ * `open()` cancels any pending close. Clears the parked state — an explicit
1577
+ * close frame on a parked pty (the user switched back and closed the tab)
1578
+ * still kills it.
1579
+ */
1580
+ scheduleClose(key, delayMs) {
1581
+ if (this.sessions.get(key) === void 0) return;
1582
+ this.cancelClose(key);
1583
+ const timer = setTimeout(() => {
1584
+ this.close(key);
1585
+ }, delayMs);
1586
+ this.pendingCloses.set(key, timer);
1587
+ }
1588
+ /**
1589
+ * Park a terminal: the owning tab's view unmounted because the user
1590
+ * switched to another conversation, but the tab is still open in its
1591
+ * session's persisted state. Cancels any pending grace close and marks
1592
+ * the pty so the host's `ws.on('close')` handler does NOT start the
1593
+ * reconnect-grace countdown — the pty stays alive until the user switches
1594
+ * back (a reconnecting view's `open()` clears this) or explicitly closes
1595
+ * the tab (a `{type:'close'}` frame's `scheduleClose` clears this).
1596
+ */
1597
+ park(key) {
1598
+ if (this.sessions.get(key) === void 0) return;
1599
+ this.cancelClose(key);
1600
+ this.parked.add(key);
1601
+ }
1602
+ /** Whether this pty was parked (its view unmounted for a session switch). */
1603
+ isParked(key) {
1604
+ return this.parked.has(key);
1605
+ }
1606
+ /** Cancel a pending scheduled close (the terminal is being reopened).
1607
+ * Also clears the parked state — a reconnecting view reattaches a parked
1608
+ * pty and resumes normal lifecycle. */
1609
+ cancelClose(key) {
1610
+ const timer = this.pendingCloses.get(key);
1611
+ if (timer !== void 0) {
1612
+ clearTimeout(timer);
1613
+ this.pendingCloses.delete(key);
1614
+ }
1615
+ this.parked.delete(key);
1616
+ }
1617
+ /** Resolve a live handle by key, or undefined. */
1618
+ get(key) {
1619
+ return this.sessions.get(key);
1620
+ }
1621
+ /** Close a terminal and drop its state (the owning tab was closed). */
1622
+ close(key) {
1623
+ this.cancelClose(key);
1624
+ const handle = this.sessions.get(key);
1625
+ if (handle === void 0) return;
1626
+ this.sessions.delete(key);
1627
+ try {
1628
+ handle.pty.kill();
1629
+ } catch {}
1630
+ }
1631
+ /** Close every terminal (plugin teardown). */
1632
+ disposeAll() {
1633
+ for (const timer of this.pendingCloses.values()) clearTimeout(timer);
1634
+ this.pendingCloses.clear();
1635
+ for (const key of [...this.sessions.keys()]) this.close(key);
1636
+ }
1637
+ };
1638
+ /**
1639
+ * Candidate directories that may contain a `pwsh.exe` on Windows: PATH
1640
+ * entries first, then the well-known machine/user install locations
1641
+ * (including preview channels and per-user MSI/portable layouts). The
1642
+ * machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
1643
+ * 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
1644
+ * finds a 64-bit PowerShell 7 install. De-duped while preserving priority
1645
+ * order.
1646
+ */
1647
+ function windowsPwshCandidateDirs(env) {
1648
+ const dirs = [];
1649
+ const pathEntries = env.PATH;
1650
+ if (pathEntries !== void 0) for (const entry of pathEntries.split(";")) {
1651
+ const trimmed = entry.trim();
1652
+ if (trimmed !== "") dirs.push(trimmed);
1653
+ }
1654
+ for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
1655
+ if (programFiles === void 0 || programFiles.trim() === "") continue;
1656
+ dirs.push(join(programFiles, "PowerShell", "7"));
1657
+ dirs.push(join(programFiles, "PowerShell", "7-preview"));
1658
+ }
1659
+ const localAppData = env.LOCALAPPDATA;
1660
+ if (localAppData !== void 0 && localAppData.trim() !== "") {
1661
+ dirs.push(join(localAppData, "Microsoft", "PowerShell", "7"));
1662
+ dirs.push(join(localAppData, "Microsoft", "PowerShell", "7-preview"));
1663
+ dirs.push(join(localAppData, "Programs", "PowerShell", "7"));
1664
+ dirs.push(join(localAppData, "Programs", "PowerShell", "7-preview"));
1665
+ }
1666
+ return [...new Set(dirs)];
1667
+ }
1668
+ /**
1669
+ * The interactive shell for this platform, resolved like a terminal
1670
+ * emulator: an explicitly configured shell (the `shell` config field) wins,
1671
+ * then `$SHELL` on POSIX (deployment override), then the account's login
1672
+ * shell from passwd, then `/bin/bash`. The passwd step matters because
1673
+ * service managers and container inits often start dsh without `SHELL`, and
1674
+ * the tab should still open the user's login shell (e.g. zsh) instead of
1675
+ * silently degrading to bash.
1676
+ *
1677
+ * Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
1678
+ * before any resolution, so PowerShell 7 users always got a legacy shell
1679
+ * without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
1680
+ * chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
1681
+ * `pwsh.exe` found on PATH or in a known install directory → the 5.1
1682
+ * fallback (machines without PowerShell 7 keep working).
1683
+ */
1684
+ function defaultShell(options = {}) {
1685
+ const platform = options.platform ?? process.platform;
1686
+ const env = options.env ?? process.env;
1687
+ const exists = options.exists ?? existsSync;
1688
+ const explicit = options.explicit;
1689
+ if (explicit !== void 0 && explicit.trim() !== "") return explicit.trim();
1690
+ if (platform === "win32") {
1691
+ const envShell = env.DSH_SIDEBAR_SHELL;
1692
+ if (envShell !== void 0 && envShell.trim() !== "") return envShell.trim();
1693
+ for (const dir of windowsPwshCandidateDirs(env)) {
1694
+ const candidate = join(dir, "pwsh.exe");
1695
+ if (exists(candidate)) return candidate;
1696
+ }
1697
+ return "powershell.exe";
1698
+ }
1699
+ const envShell = env.SHELL;
1700
+ if (envShell !== void 0 && envShell.trim() !== "") return envShell.trim();
1701
+ try {
1702
+ const loginShell = userInfo().shell;
1703
+ if (typeof loginShell === "string" && loginShell.trim() !== "") return loginShell;
1704
+ } catch {}
1705
+ return "/bin/bash";
1706
+ }
1707
+ /**
1708
+ * A short display name for a shell executable, used as the terminal tab
1709
+ * title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
1710
+ * Falls back to the raw value when no basename can be derived.
1711
+ */
1712
+ function shellDisplayName(shell) {
1713
+ const normalized = shell.replace(/\\/g, "/");
1714
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
1715
+ if (base === "") return shell;
1716
+ return base.replace(/\.(exe|cmd|bat)$/i, "");
1717
+ }
1718
+ /**
1719
+ * Spawn arguments that make the shell behave like a terminal-emulator tab:
1720
+ * POSIX shells start as login shells (`-l`) so they read the profile files
1721
+ * (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
1722
+ *
1723
+ * When explicit `configured` args are supplied they REPLACE the platform
1724
+ * defaults entirely, giving deployments full control over shell startup.
1725
+ */
1726
+ function shellSpawnArgs(configured = []) {
1727
+ if (configured.length > 0) return [...configured];
1728
+ return process.platform === "win32" ? [] : ["-l"];
1729
+ }
1730
+ //#endregion
1731
+ //#region src/agent-pty.ts
1732
+ /**
1733
+ * Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
1734
+ * sessions created by the model through the `terminal_create` tool. Each
1735
+ * handle survives across tool calls (and across WebSocket disconnects from
1736
+ * the sidebar view) until the model calls `terminal_close` or the user
1737
+ * closes the corresponding sidebar tab — tmux semantics, scoped per agent
1738
+ * session.
1739
+ *
1740
+ * This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
1741
+ * `${sessionId}:${tabId}` and capped per session, while agent terminals are
1742
+ * keyed by uuid and uncapped (the model is trusted to close unused ones).
1743
+ * Both registries share the same shell resolver and spawn-helper fix.
1744
+ */
1745
+ /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
1746
+ const TRANSCRIPT_LIMIT = 1 << 20;
1747
+ /** POSIX signals the registry forwards to a live pty. */
1748
+ const ALLOWED_SIGNALS = [
1749
+ "SIGINT",
1750
+ "SIGTERM",
1751
+ "SIGKILL",
1752
+ "SIGHUP",
1753
+ "SIGTSTP"
1754
+ ];
1755
+ /** Largest pty dimension the registry accepts (mirrors the tool contract). */
1756
+ const TERMINAL_DIM_MAX = 1024;
1757
+ /** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
1758
+ function clampDims(cols, rows) {
1759
+ const clamp = (value) => Math.min(TERMINAL_DIM_MAX, Math.max(2, Math.floor(value)));
1760
+ return {
1761
+ cols: clamp(cols),
1762
+ rows: clamp(rows)
1763
+ };
1764
+ }
1765
+ /** Map a POSIX signal number to its conventional name (best-effort). */
1766
+ const SIGNAL_NAMES = {
1767
+ 1: "SIGHUP",
1768
+ 2: "SIGINT",
1769
+ 3: "SIGQUIT",
1770
+ 4: "SIGILL",
1771
+ 6: "SIGABRT",
1772
+ 9: "SIGKILL",
1773
+ 11: "SIGSEGV",
1774
+ 13: "SIGPIPE",
1775
+ 14: "SIGALRM",
1776
+ 15: "SIGTERM",
1777
+ 17: "SIGCHLD",
1778
+ 18: "SIGCONT",
1779
+ 19: "SIGSTOP",
1780
+ 20: "SIGTSTP"
1781
+ };
1782
+ /** Convert a raw signal number to a name (or null when absent/unknown). */
1783
+ function signalNameOf(signal) {
1784
+ if (signal === null || signal === void 0) return null;
1785
+ return SIGNAL_NAMES[signal] ?? `signal ${signal}`;
1786
+ }
1787
+ /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
1788
+ function locateNeedle(transcript, needle) {
1789
+ if (needle === "") return void 0;
1790
+ const idx = transcript.indexOf(needle);
1791
+ if (idx === -1) return void 0;
1792
+ let line = 0;
1793
+ let lineStart = 0;
1794
+ for (let i = 0; i < idx; i += 1) if (transcript.charCodeAt(i) === 10) {
1795
+ line += 1;
1796
+ lineStart = i + 1;
1797
+ }
1798
+ return {
1799
+ line,
1800
+ column: idx - lineStart
1801
+ };
1802
+ }
1803
+ /** Snapshot projection of a handle (drops the pty reference and transcript). */
1804
+ function snapshotOf(handle) {
1805
+ const out = {
1806
+ uuid: handle.uuid,
1807
+ title: handle.title,
1808
+ command: handle.command,
1809
+ exited: handle.exited
1810
+ };
1811
+ if (handle.exited) {
1812
+ out.exitCode = handle.exitCode ?? null;
1813
+ out.exitSignal = signalNameOf(handle.exitSignal);
1814
+ }
1815
+ return out;
1816
+ }
1817
+ /**
1818
+ * The agent terminal registry. The constructor takes the resolved shell
1819
+ * binary (the same `defaultShell()` the UI-tab registry uses) and runs the
1820
+ * spawn-helper chmod fix once at construction so the first agent terminal
1821
+ * does not race a lazy fixer.
1822
+ */
1823
+ var AgentPtyRegistry = class {
1824
+ shell;
1825
+ shellArgs;
1826
+ nodePty;
1827
+ sessions = /* @__PURE__ */ new Map();
1828
+ changeListeners = /* @__PURE__ */ new Set();
1829
+ constructor(shell, shellArgs = [], nodePty = loadRequiredNodePty()) {
1830
+ this.shell = shell;
1831
+ this.shellArgs = shellArgs;
1832
+ this.nodePty = nodePty;
1833
+ ensureSpawnHelper();
1834
+ }
1835
+ /**
1836
+ * Spawn one agent terminal: start the shell in `cwd`, then write
1837
+ * `command + '\n'` to stdin so the command runs in the fresh shell. The
1838
+ * terminal stays alive after the command exits — the model can send more
1839
+ * input through `terminal_send` until it calls `terminal_close` or the
1840
+ * user closes the sidebar tab. An empty `command` spawns a bare shell.
1841
+ * @returns the new handle's uuid (the model-facing opaque id).
1842
+ */
1843
+ create(sessionId, title, command, cwd, cols = 80, rows = 24, shell, shellArgs) {
1844
+ const uuid = randomUUID();
1845
+ const dims = clampDims(cols, rows);
1846
+ const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
1847
+ name: "xterm-256color",
1848
+ cols: dims.cols,
1849
+ rows: dims.rows,
1850
+ cwd,
1851
+ env: { ...process.env }
1852
+ });
1853
+ const handle = {
1854
+ uuid,
1855
+ sessionId,
1856
+ title,
1857
+ command,
1858
+ cwd,
1859
+ pty,
1860
+ transcript: "",
1861
+ exited: false
1862
+ };
1863
+ pty.onData((data) => {
1864
+ handle.transcript += data;
1865
+ if (handle.transcript.length > TRANSCRIPT_LIMIT) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT);
1866
+ });
1867
+ pty.onExit(({ exitCode, signal }) => {
1868
+ handle.exited = true;
1869
+ handle.exitCode = exitCode;
1870
+ handle.exitSignal = signal;
1871
+ this.notify();
1872
+ });
1873
+ if (command !== "") try {
1874
+ pty.write(`${command}\r`);
1875
+ } catch {}
1876
+ this.sessions.set(uuid, handle);
1877
+ this.notify();
1878
+ return uuid;
1879
+ }
1880
+ /** All live agent terminals belonging to one conversation. */
1881
+ list(sessionId) {
1882
+ const out = [];
1883
+ for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) out.push(snapshotOf(handle));
1884
+ return out;
1885
+ }
1886
+ /** Resolve a live handle by uuid, or throw `not-found`. */
1887
+ expect(uuid) {
1888
+ const handle = this.sessions.get(uuid);
1889
+ if (handle === void 0) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
1890
+ return handle;
1891
+ }
1892
+ /**
1893
+ * Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
1894
+ * The model-facing tools call this before every uuid-keyed operation: a
1895
+ * uuid from another session is indistinguishable from an unknown one, so a
1896
+ * model can never reach (or probe) a terminal it does not own.
1897
+ */
1898
+ assertOwned(uuid, sessionId) {
1899
+ const handle = this.expect(uuid);
1900
+ if (handle.sessionId !== sessionId) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
1901
+ return handle;
1902
+ }
1903
+ /** Resolve a handle's snapshot, or undefined if it does not exist. */
1904
+ snapshot(uuid) {
1905
+ const handle = this.sessions.get(uuid);
1906
+ return handle === void 0 ? void 0 : snapshotOf(handle);
1907
+ }
1908
+ /** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
1909
+ send(uuid, text) {
1910
+ const handle = this.expect(uuid);
1911
+ if (handle.exited) throw new SidebarError("bad-request", `agent terminal "${uuid}" has exited`, 400);
1912
+ handle.pty.write(text);
1913
+ }
1914
+ /**
1915
+ * Read one bounded page of the retained transcript. `offset` is a 0-based
1916
+ * line index from the start of the retained transcript (default 0);
1917
+ * `count` caps the page size (default 500). A negative `offset` reads
1918
+ * from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
1919
+ * so the model can paginate.
1920
+ */
1921
+ read(uuid, offset, count) {
1922
+ const lines = this.expect(uuid).transcript.split("\n");
1923
+ const totalLines = lines.length;
1924
+ const pageSize = Math.max(1, Math.min(count ?? 500, 500));
1925
+ let start;
1926
+ if (offset === void 0 || offset === 0) start = 0;
1927
+ else if (offset < 0) start = Math.max(0, totalLines + offset);
1928
+ else start = Math.min(offset, totalLines);
1929
+ const end = Math.min(start + pageSize, totalLines);
1930
+ return {
1931
+ text: lines.slice(start, end).join("\n"),
1932
+ totalLines,
1933
+ lineBegin: start,
1934
+ lineEnd: end
1935
+ };
1936
+ }
1937
+ /**
1938
+ * Resize a terminal's pty, clamped to the 2..1024 sane range.
1939
+ * @returns the dimensions actually applied (the caller echoes these, so the
1940
+ * reported value always matches the pty).
1941
+ */
1942
+ resize(uuid, cols, rows) {
1943
+ const handle = this.expect(uuid);
1944
+ const dims = clampDims(cols, rows);
1945
+ if (!handle.exited) handle.pty.resize(dims.cols, dims.rows);
1946
+ return dims;
1947
+ }
1948
+ /**
1949
+ * Wait for `needle` to appear in a terminal's transcript, or for the
1950
+ * terminal to exit, or for the timeout to elapse — whichever happens
1951
+ * first. The wait polls the live transcript every ~50ms and short-circuits
1952
+ * on `signal` abort (re-thrown as the abort reason so the tool layer
1953
+ * surfaces cancellation).
1954
+ *
1955
+ * The match scans the FULL retained transcript on each poll, not just the
1956
+ * delta since the last poll — a needle that scrolled past the most recent
1957
+ * chunk but is still within the ~1 MiB bound is still a match. The
1958
+ * returned line/column locate the FIRST occurrence (oldest), which is what
1959
+ * a user watching the terminal would have seen first.
1960
+ *
1961
+ * The implementation uses polling (not pty onData subscription) because
1962
+ * node-pty's onData fires before the registry's own onData listener
1963
+ * updates the transcript (listener order is not guaranteed), and on
1964
+ * Windows ConPTY output can arrive in bursts with batching delays that
1965
+ * make event-driven wakeups unreliable. A 50ms poll is fast enough for
1966
+ * interactive use and simple enough to be obviously correct.
1967
+ * @param uuid - terminal to watch.
1968
+ * @param needle - substring to search for (case-sensitive, verbatim).
1969
+ * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
1970
+ * @param signal - caller-owned cancellation; aborts the wait re-throwing.
1971
+ * @returns one of `found` / `timeout` / `exited`.
1972
+ */
1973
+ async waitFor(uuid, needle, timeoutMs = 1e4, signal) {
1974
+ if (needle === "") throw new SidebarError("bad-request", "needle must be a non-empty string", 400);
1975
+ const handle = this.expect(uuid);
1976
+ const timeout = Math.max(100, Math.floor(timeoutMs));
1977
+ const start = Date.now();
1978
+ const deadline = start + timeout;
1979
+ if (handle.exited) return {
1980
+ kind: "exited",
1981
+ needle,
1982
+ exitCode: handle.exitCode ?? null,
1983
+ exitSignal: signalNameOf(handle.exitSignal)
1984
+ };
1985
+ const firstHit = locateNeedle(handle.transcript, needle);
1986
+ if (firstHit !== void 0) return {
1987
+ kind: "found",
1988
+ needle,
1989
+ line: firstHit.line,
1990
+ column: firstHit.column,
1991
+ elapsedMs: Date.now() - start
1992
+ };
1993
+ while (true) {
1994
+ if (signal?.aborted) signal.throwIfAborted();
1995
+ if (handle.exited) return {
1996
+ kind: "exited",
1997
+ needle,
1998
+ exitCode: handle.exitCode ?? null,
1999
+ exitSignal: signalNameOf(handle.exitSignal)
2000
+ };
2001
+ const hit = locateNeedle(handle.transcript, needle);
2002
+ if (hit !== void 0) return {
2003
+ kind: "found",
2004
+ needle,
2005
+ line: hit.line,
2006
+ column: hit.column,
2007
+ elapsedMs: Date.now() - start
2008
+ };
2009
+ if (Date.now() >= deadline) return {
2010
+ kind: "timeout",
2011
+ needle,
2012
+ timeoutMs: timeout,
2013
+ totalLines: handle.transcript.split("\n").length
2014
+ };
2015
+ await new Promise((resolve) => {
2016
+ const t = setTimeout(resolve, 50);
2017
+ if (typeof t === "object" && "unref" in t) t.unref();
2018
+ });
2019
+ }
2020
+ }
2021
+ /**
2022
+ * Send a POSIX signal to a terminal's foreground process.
2023
+ *
2024
+ * Two delivery paths, by signal kind:
2025
+ * - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
2026
+ * writing the corresponding control character to the pty stdin. This is
2027
+ * how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
2028
+ * line discipline (POSIX ISIG mode) or the ConPTY input pipeline
2029
+ * (Windows), which translates it into a SIGINT/SIGTSTP for the
2030
+ * foreground process group. This works on every platform — calling
2031
+ * `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
2032
+ * but writing `\x03` is universally correct.
2033
+ * - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
2034
+ * which maps to the platform's process-termination path (POSIX
2035
+ * `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
2036
+ * control characters.
2037
+ */
2038
+ signal(uuid, signal) {
2039
+ const handle = this.expect(uuid);
2040
+ if (handle.exited) return;
2041
+ if (signal === "SIGINT" || signal === "SIGTSTP") {
2042
+ const ctrlByte = signal === "SIGINT" ? "" : "";
2043
+ try {
2044
+ handle.pty.write(ctrlByte);
2045
+ } catch {}
2046
+ return;
2047
+ }
2048
+ try {
2049
+ handle.pty.kill(signal);
2050
+ } catch {
2051
+ try {
2052
+ handle.pty.kill();
2053
+ } catch {}
2054
+ }
2055
+ }
2056
+ /**
2057
+ * Close a terminal and drop its state. Idempotent: a second close of the
2058
+ * same uuid is a no-op. Returns true iff a live handle was actually
2059
+ * dropped.
2060
+ */
2061
+ close(uuid) {
2062
+ const handle = this.sessions.get(uuid);
2063
+ if (handle === void 0) return false;
2064
+ this.sessions.delete(uuid);
2065
+ try {
2066
+ handle.pty.kill();
2067
+ } catch {}
2068
+ this.notify();
2069
+ return true;
2070
+ }
2071
+ /** Resolve a live handle by uuid (for the WS attach path). */
2072
+ get(uuid) {
2073
+ return this.sessions.get(uuid);
2074
+ }
2075
+ /**
2076
+ * Subscribe to registry changes (create / close / exit). The sidebar push
2077
+ * endpoint uses this to forward snapshots to the connected view. Returns
2078
+ * the unsubscribe function.
2079
+ */
2080
+ subscribe(listener) {
2081
+ this.changeListeners.add(listener);
2082
+ return () => {
2083
+ this.changeListeners.delete(listener);
2084
+ };
2085
+ }
2086
+ /** Close every agent terminal (plugin teardown). */
2087
+ disposeAll() {
2088
+ for (const uuid of [...this.sessions.keys()]) this.close(uuid);
2089
+ }
2090
+ /** Fire every change listener (callers wrap in try/catch if needed). */
2091
+ notify() {
2092
+ for (const listener of [...this.changeListeners]) try {
2093
+ listener();
2094
+ } catch {}
2095
+ }
2096
+ };
2097
+ //#endregion
2098
+ //#region src/tools.ts
2099
+ /**
2100
+ * Eight model-facing tools for the agent-owned sidebar terminals (tmux
2101
+ * semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
2102
+ * close, list). Each tool binds to the calling agent's session through
2103
+ * `exec.agent.session.id`, so the model never passes a sessionId — the
2104
+ * agent identity is the scope.
2105
+ *
2106
+ * Conventions (per plugin-development-guide.md §3):
2107
+ * C1 — parameters schema-validated before `execute` runs.
2108
+ * C4 — `execute` returns one canonical JSON value; `render` is a separate
2109
+ * pure text projection.
2110
+ * C6 — `exec.signal.throwIfAborted()` before any spawn.
2111
+ * C10 — no UI/transport vocabulary in the canonical value.
2112
+ */
2113
+ /** Maximum UTF-8 bytes of one `terminal_read` result text. */
2114
+ const READ_BYTE_LIMIT = 262144;
2115
+ /**
2116
+ * Bound a string to a byte limit, marking truncation. Truncation never
2117
+ * splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
2118
+ * the walk-back retreats to the sequence's leading byte so the retained
2119
+ * prefix decodes cleanly (a split would decode to U+FFFD).
2120
+ * @internal exported for the unit tests, like {@link snapshotOf}.
2121
+ */
2122
+ function boundBytes(text, maxBytes) {
2123
+ const buf = Buffer.from(text, "utf8");
2124
+ if (buf.byteLength <= maxBytes) return {
2125
+ text,
2126
+ truncated: false
2127
+ };
2128
+ let end = maxBytes;
2129
+ while (end > 0 && ((buf[end] ?? 0) & 192) === 128) end -= 1;
2130
+ return {
2131
+ text: buf.subarray(0, end).toString("utf8"),
2132
+ truncated: true
2133
+ };
2134
+ }
2135
+ /** Pure text projection helper (the canonical value is already structured). */
2136
+ function textRender$1(fn) {
2137
+ return (_args, value) => [{
2138
+ type: "text",
2139
+ text: fn(value)
2140
+ }];
2141
+ }
2142
+ /** Extract the calling agent or throw the canonical "no agent" error. */
2143
+ function requireAgent$1(agent) {
2144
+ if (agent === void 0) throw new Error("sidebar terminal tools require an initiating agent");
2145
+ return agent;
2146
+ }
2147
+ /** Resolve the calling agent's session id (the registry scope + ownership key). */
2148
+ function sessionIdOf$1(exec) {
2149
+ return requireAgent$1(exec.agent).session.id;
2150
+ }
2151
+ /**
2152
+ * Register the eight terminal tools against the host tool registry. The
2153
+ * `resolveCwd` callback threads the live session cwd (authoritative from the
2154
+ * session store, falling back to the process cwd) so a freshly-created
2155
+ * terminal lands in the right directory without the model passing it.
2156
+ * Every uuid-keyed tool first asserts the terminal belongs to the calling
2157
+ * session (`registry.assertOwned`), so one agent can never reach another
2158
+ * session's terminals.
2159
+ * @param ctx - host plugin context (carries the tools service).
2160
+ * @param registry - the agent-owned terminal registry.
2161
+ * @param resolveCwd - async cwd resolver for one session id. Resolves through
2162
+ * the session header, the client-supplied cwd, and the persistence index
2163
+ * before falling back to the host process cwd (production always provides
2164
+ * persistence, so the fallback is reached only in tests / stripped-down hosts).
2165
+ * @returns a disposer that unregisters all eight tools (the caller gates
2166
+ * registration on the side-card setting and calls this to turn them off).
2167
+ */
2168
+ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
2169
+ const disposers = [];
2170
+ const register = (tool) => {
2171
+ disposers.push(ctx.tools.register(tool));
2172
+ };
2173
+ register(defineTool({
2174
+ name: "terminal_create",
2175
+ description: "Open a persistent terminal in the sidebar and run a command in it. Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), read output with terminal_read, send Ctrl+C with terminal_signal(signal=\"SIGINT\"), and close it with terminal_close when done. Use this for interactive shells, REPLs, long-running dev servers, or any work that needs persistent terminal state across tool calls. The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.",
2176
+ parameters: {
2177
+ title: {
2178
+ type: "string",
2179
+ required: true,
2180
+ description: "Short human-readable label for the terminal tab (e.g. \"dev server\", \"python repl\")."
2181
+ },
2182
+ command: {
2183
+ type: "string",
2184
+ required: true,
2185
+ description: "Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass \"\" to open a bare shell with no command."
2186
+ }
2187
+ },
2188
+ output: {
2189
+ schema: {
2190
+ type: "object",
2191
+ additionalProperties: false,
2192
+ properties: {
2193
+ uuid: {
2194
+ type: "string",
2195
+ required: true,
2196
+ description: "Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close."
2197
+ },
2198
+ title: {
2199
+ type: "string",
2200
+ required: true,
2201
+ description: "The title you provided (echoed for confirmation)."
2202
+ }
2203
+ }
2204
+ },
2205
+ render: textRender$1((v) => `Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`)
2206
+ },
2207
+ execute: async (args, exec) => {
2208
+ exec.signal.throwIfAborted();
2209
+ const sessionId = sessionIdOf$1(exec);
2210
+ const cwd = await resolveCwd(sessionId);
2211
+ const { shell, shellArgs } = readShellOverrides();
2212
+ return {
2213
+ uuid: registry.create(sessionId, args.title, args.command, cwd, 80, 24, shell, shellArgs),
2214
+ title: args.title
2215
+ };
2216
+ }
2217
+ }));
2218
+ register(defineTool({
2219
+ name: "terminal_list",
2220
+ description: "List every terminal the current agent has opened in this session. Returns each terminal's uuid, title, the command it was started with, and whether the top-level process has exited (with exit code/signal if so). Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.",
2221
+ parameters: {},
2222
+ output: {
2223
+ schema: {
2224
+ type: "array",
2225
+ items: {
2226
+ type: "object",
2227
+ additionalProperties: false,
2228
+ properties: {
2229
+ uuid: {
2230
+ type: "string",
2231
+ required: true
2232
+ },
2233
+ title: {
2234
+ type: "string",
2235
+ required: true
2236
+ },
2237
+ command: {
2238
+ type: "string",
2239
+ required: true
2240
+ },
2241
+ exited: {
2242
+ type: "boolean",
2243
+ required: true
2244
+ },
2245
+ exitCode: { oneOf: [{ type: "integer" }, { type: "null" }] },
2246
+ exitSignal: { oneOf: [{ type: "string" }, { type: "null" }] }
2247
+ }
2248
+ }
2249
+ },
2250
+ render: (_args, value) => {
2251
+ const list = value;
2252
+ if (list.length === 0) return [{
2253
+ type: "text",
2254
+ text: "No agent terminals open in this session."
2255
+ }];
2256
+ return [{
2257
+ type: "text",
2258
+ text: `Agent terminals in this session:\n${list.map((t) => {
2259
+ const status = t.exited ? `exited (code ${t.exitCode ?? "?"}, signal ${t.exitSignal ?? "none"})` : "running";
2260
+ return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`;
2261
+ }).join("\n")}`
2262
+ }];
2263
+ }
2264
+ },
2265
+ execute: (_args, exec) => {
2266
+ const sessionId = sessionIdOf$1(exec);
2267
+ return Promise.resolve(registry.list(sessionId));
2268
+ }
2269
+ }));
2270
+ register(defineTool({
2271
+ name: "terminal_send",
2272
+ description: "Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. The text is written verbatim to the pty stdin. To submit a command, set submit=true (appends an Enter key); do NOT put \"\\n\" or \"\\r\" in the text yourself. To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal=\"SIGINT\" — do NOT try to send the control character \"\\u0003\" as text. Use terminal_signal with signal=\"SIGTSTP\" for Ctrl+Z (suspend) as well. This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. Throws if the terminal has exited.",
2273
+ parameters: {
2274
+ uuid: {
2275
+ type: "string",
2276
+ required: true,
2277
+ description: "Terminal uuid from terminal_create or terminal_list."
2278
+ },
2279
+ text: {
2280
+ type: "string",
2281
+ required: true,
2282
+ description: "UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead."
2283
+ },
2284
+ submit: {
2285
+ type: "boolean",
2286
+ description: "Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences."
2287
+ }
2288
+ },
2289
+ output: {
2290
+ schema: {
2291
+ type: "object",
2292
+ additionalProperties: false,
2293
+ properties: {
2294
+ uuid: {
2295
+ type: "string",
2296
+ required: true
2297
+ },
2298
+ bytes: {
2299
+ type: "integer",
2300
+ required: true,
2301
+ description: "Number of UTF-8 bytes written (including the Enter key if submit was true)."
2302
+ }
2303
+ }
2304
+ },
2305
+ render: textRender$1((v) => `Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`)
2306
+ },
2307
+ execute: (args, exec) => {
2308
+ exec.signal.throwIfAborted();
2309
+ const sessionId = sessionIdOf$1(exec);
2310
+ registry.assertOwned(args.uuid, sessionId);
2311
+ const payload = args.submit === true ? `${args.text}\r` : args.text;
2312
+ registry.send(args.uuid, payload);
2313
+ return Promise.resolve({
2314
+ uuid: args.uuid,
2315
+ bytes: Buffer.byteLength(payload, "utf8")
2316
+ });
2317
+ }
2318
+ }));
2319
+ register(defineTool({
2320
+ name: "terminal_read",
2321
+ description: "Read a bounded page of retained output from an agent terminal without sending input. The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). Returns `totalLines` so you know how much scrollback remains. Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.",
2322
+ parameters: {
2323
+ uuid: {
2324
+ type: "string",
2325
+ required: true,
2326
+ description: "Terminal uuid from terminal_create or terminal_list."
2327
+ },
2328
+ offset: {
2329
+ type: "number",
2330
+ description: "0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines)."
2331
+ },
2332
+ count: {
2333
+ type: "number",
2334
+ description: "Maximum lines to return (default 500, hard cap 500)."
2335
+ }
2336
+ },
2337
+ output: {
2338
+ schema: {
2339
+ type: "object",
2340
+ additionalProperties: false,
2341
+ properties: {
2342
+ text: {
2343
+ type: "string",
2344
+ required: true,
2345
+ description: "The slice of transcript for the requested page."
2346
+ },
2347
+ totalLines: {
2348
+ type: "integer",
2349
+ required: true,
2350
+ description: "Total lines in the retained transcript."
2351
+ },
2352
+ lineBegin: {
2353
+ type: "integer",
2354
+ required: true,
2355
+ description: "0-based index of the first line in `text` (inclusive)."
2356
+ },
2357
+ lineEnd: {
2358
+ type: "integer",
2359
+ required: true,
2360
+ description: "0-based index of the last line in `text` (exclusive)."
2361
+ },
2362
+ truncated: {
2363
+ type: "boolean",
2364
+ required: true,
2365
+ description: "Whether `text` was truncated to fit the 256 KiB read cap."
2366
+ }
2367
+ }
2368
+ },
2369
+ render: (_args, value) => {
2370
+ const v = value;
2371
+ return [{
2372
+ type: "text",
2373
+ text: `${`[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? "; truncated to 256KiB" : ""}]`}\n${v.text}`
2374
+ }];
2375
+ }
2376
+ },
2377
+ execute: (args, exec) => {
2378
+ exec.signal.throwIfAborted();
2379
+ const sessionId = sessionIdOf$1(exec);
2380
+ registry.assertOwned(args.uuid, sessionId);
2381
+ const result = registry.read(args.uuid, args.offset, args.count);
2382
+ const bounded = boundBytes(result.text, READ_BYTE_LIMIT);
2383
+ return Promise.resolve({
2384
+ text: bounded.text,
2385
+ totalLines: result.totalLines,
2386
+ lineBegin: result.lineBegin,
2387
+ lineEnd: result.lineEnd,
2388
+ truncated: bounded.truncated
2389
+ });
2390
+ }
2391
+ }));
2392
+ register(defineTool({
2393
+ name: "terminal_wait_for",
2394
+ description: "Block until a substring appears in a terminal's retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. Use this to synchronize on command completion cues ( e.g. a shell prompt, \"done\", \"Listening on\", \"Build successful\" ) without busy-polling terminal_read. The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.",
2395
+ parameters: {
2396
+ uuid: {
2397
+ type: "string",
2398
+ required: true,
2399
+ description: "Terminal uuid from terminal_create or terminal_list."
2400
+ },
2401
+ needle: {
2402
+ type: "string",
2403
+ required: true,
2404
+ description: "Substring to wait for (case-sensitive, verbatim). Must be non-empty."
2405
+ },
2406
+ timeout_ms: {
2407
+ type: "number",
2408
+ description: "Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms."
2409
+ }
2410
+ },
2411
+ output: {
2412
+ schema: { oneOf: [
2413
+ {
2414
+ type: "object",
2415
+ additionalProperties: false,
2416
+ properties: {
2417
+ kind: {
2418
+ type: "string",
2419
+ required: true,
2420
+ const: "found"
2421
+ },
2422
+ needle: {
2423
+ type: "string",
2424
+ required: true
2425
+ },
2426
+ line: {
2427
+ type: "integer",
2428
+ required: true,
2429
+ description: "0-based line index in the retained transcript where the needle first appeared."
2430
+ },
2431
+ column: {
2432
+ type: "integer",
2433
+ required: true,
2434
+ description: "0-based column index within that line where the match starts."
2435
+ },
2436
+ elapsedMs: {
2437
+ type: "integer",
2438
+ required: true,
2439
+ description: "Wall-clock milliseconds from wait start to match."
2440
+ }
2441
+ }
2442
+ },
2443
+ {
2444
+ type: "object",
2445
+ additionalProperties: false,
2446
+ properties: {
2447
+ kind: {
2448
+ type: "string",
2449
+ required: true,
2450
+ const: "timeout"
2451
+ },
2452
+ needle: {
2453
+ type: "string",
2454
+ required: true
2455
+ },
2456
+ timeoutMs: {
2457
+ type: "integer",
2458
+ required: true,
2459
+ description: "The configured timeout that elapsed."
2460
+ },
2461
+ totalLines: {
2462
+ type: "integer",
2463
+ required: true,
2464
+ description: "Total lines retained when the timeout fired. Call terminal_read to inspect the tail."
2465
+ }
2466
+ }
2467
+ },
2468
+ {
2469
+ type: "object",
2470
+ additionalProperties: false,
2471
+ properties: {
2472
+ kind: {
2473
+ type: "string",
2474
+ required: true,
2475
+ const: "exited"
2476
+ },
2477
+ needle: {
2478
+ type: "string",
2479
+ required: true
2480
+ },
2481
+ exitCode: {
2482
+ oneOf: [{ type: "integer" }, { type: "null" }],
2483
+ description: "Exit code, if known."
2484
+ },
2485
+ exitSignal: {
2486
+ oneOf: [{ type: "string" }, { type: "null" }],
2487
+ description: "Exit signal name, if killed by a signal."
2488
+ }
2489
+ }
2490
+ }
2491
+ ] },
2492
+ render: (_args, value) => {
2493
+ const v = value;
2494
+ if (v.kind === "found") return [{
2495
+ type: "text",
2496
+ text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).`
2497
+ }];
2498
+ if (v.kind === "timeout") return [{
2499
+ type: "text",
2500
+ text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.`
2501
+ }];
2502
+ const exitInfo = v.exitCode !== void 0 && v.exitCode !== null ? ` (exit code ${v.exitCode})` : "";
2503
+ return [{
2504
+ type: "text",
2505
+ text: `Terminal exited before "${v.needle}" appeared${exitInfo}.`
2506
+ }];
2507
+ }
2508
+ },
2509
+ async execute(args, exec) {
2510
+ exec.signal.throwIfAborted();
2511
+ const sessionId = sessionIdOf$1(exec);
2512
+ registry.assertOwned(args.uuid, sessionId);
2513
+ const timeoutMs = args.timeout_ms ?? 1e4;
2514
+ return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal);
2515
+ }
2516
+ }));
2517
+ register(defineTool({
2518
+ name: "terminal_resize",
2519
+ description: "Resize an agent terminal's pty ( cols × rows ). The host clamps both to a 2..1024 sane range. Most shells redraw their prompt and any full-screen TUI on the next output frame. No-op if the terminal has exited. Returns the dimensions actually applied.",
2520
+ parameters: {
2521
+ uuid: {
2522
+ type: "string",
2523
+ required: true,
2524
+ description: "Terminal uuid from terminal_create or terminal_list."
2525
+ },
2526
+ cols: {
2527
+ type: "integer",
2528
+ required: true,
2529
+ description: "New column count ( clamped to 2..1024 )."
2530
+ },
2531
+ rows: {
2532
+ type: "integer",
2533
+ required: true,
2534
+ description: "New row count ( clamped to 2..1024 )."
2535
+ }
2536
+ },
2537
+ output: {
2538
+ schema: {
2539
+ type: "object",
2540
+ additionalProperties: false,
2541
+ properties: {
2542
+ uuid: {
2543
+ type: "string",
2544
+ required: true
2545
+ },
2546
+ cols: {
2547
+ type: "integer",
2548
+ required: true
2549
+ },
2550
+ rows: {
2551
+ type: "integer",
2552
+ required: true
2553
+ }
2554
+ }
2555
+ },
2556
+ render: textRender$1((v) => `Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`)
2557
+ },
2558
+ execute: (args, exec) => {
2559
+ exec.signal.throwIfAborted();
2560
+ const sessionId = sessionIdOf$1(exec);
2561
+ registry.assertOwned(args.uuid, sessionId);
2562
+ const dims = registry.resize(args.uuid, args.cols, args.rows);
2563
+ return Promise.resolve({
2564
+ uuid: args.uuid,
2565
+ ...dims
2566
+ });
2567
+ }
2568
+ }));
2569
+ register(defineTool({
2570
+ name: "terminal_signal",
2571
+ description: "Send a POSIX signal to an agent terminal's foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. Use signal=\"SIGINT\" for Ctrl+C (interrupt the running command), signal=\"SIGTERM\" to request termination, signal=\"SIGKILL\" to force-kill the pty, signal=\"SIGHUP\" to hang up (many shells exit), signal=\"SIGTSTP\" for Ctrl+Z (suspend). Do NOT try to send control characters (like \"\\u0003\") through terminal_send — use this tool instead. On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.",
2572
+ parameters: {
2573
+ uuid: {
2574
+ type: "string",
2575
+ required: true,
2576
+ description: "Terminal uuid from terminal_create or terminal_list."
2577
+ },
2578
+ signal: {
2579
+ type: "string",
2580
+ required: true,
2581
+ enum: ALLOWED_SIGNALS,
2582
+ description: "Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z)."
2583
+ }
2584
+ },
2585
+ output: {
2586
+ schema: {
2587
+ type: "object",
2588
+ additionalProperties: false,
2589
+ properties: {
2590
+ uuid: {
2591
+ type: "string",
2592
+ required: true
2593
+ },
2594
+ signal: {
2595
+ type: "string",
2596
+ required: true
2597
+ }
2598
+ }
2599
+ },
2600
+ render: textRender$1((v) => `Sent ${v.signal} to terminal ${v.uuid}.`)
2601
+ },
2602
+ execute: (args, exec) => {
2603
+ exec.signal.throwIfAborted();
2604
+ const sessionId = sessionIdOf$1(exec);
2605
+ registry.assertOwned(args.uuid, sessionId);
2606
+ registry.signal(args.uuid, args.signal);
2607
+ return Promise.resolve({
2608
+ uuid: args.uuid,
2609
+ signal: args.signal
2610
+ });
2611
+ }
2612
+ }));
2613
+ register(defineTool({
2614
+ name: "terminal_close",
2615
+ description: "Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. Idempotent: closing an already-closed uuid is a no-op. The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. Always close terminals you no longer need — the host keeps the pty alive until you do.",
2616
+ parameters: { uuid: {
2617
+ type: "string",
2618
+ required: true,
2619
+ description: "Terminal uuid from terminal_create or terminal_list."
2620
+ } },
2621
+ output: {
2622
+ schema: {
2623
+ type: "object",
2624
+ additionalProperties: false,
2625
+ properties: {
2626
+ uuid: {
2627
+ type: "string",
2628
+ required: true
2629
+ },
2630
+ closed: {
2631
+ type: "boolean",
2632
+ required: true,
2633
+ description: "Whether a live terminal was actually dropped (false if the uuid was already gone)."
2634
+ }
2635
+ }
2636
+ },
2637
+ render: textRender$1((v) => v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`)
2638
+ },
2639
+ execute: (args, exec) => {
2640
+ exec.signal.throwIfAborted();
2641
+ const sessionId = sessionIdOf$1(exec);
2642
+ registry.assertOwned(args.uuid, sessionId);
2643
+ const closed = registry.close(args.uuid);
2644
+ return Promise.resolve({
2645
+ uuid: args.uuid,
2646
+ closed
2647
+ });
2648
+ }
2649
+ }));
2650
+ return () => {
2651
+ for (const dispose of disposers) dispose();
2652
+ };
2653
+ }
2654
+ //#endregion
2655
+ //#region src/agent-opens.ts
2656
+ /**
2657
+ * The model-facing `sidebar_open` tool and its delivery registry.
2658
+ *
2659
+ * One tool lets the model actively open a local file, a local folder (as a
2660
+ * tree rooted there), or an HTTP(S) page in the CALLING session's sidebar.
2661
+ * Mirroring the agent-terminal tools, the tool binds to the calling agent's
2662
+ * session through `exec.agent.session.id` — the model never passes a
2663
+ * sessionId, and opens for non-active sessions are queued until that
2664
+ * session's sidebar view is next connected.
2665
+ *
2666
+ * Delivery is a host→browser push over the dedicated `/sidebar/ws/agent-opens`
2667
+ * endpoint (the same pattern as `/sidebar/ws/agent-terminals`): the registry
2668
+ * keeps a per-session queue; a push is consumed on send (`delivered: true`
2669
+ * means a sidebar view was attached at call time), otherwise the request
2670
+ * stays queued and is replayed when a view for that session attaches.
2671
+ *
2672
+ * Conventions (per plugin-development-guide.md §3):
2673
+ * C1 — parameters schema-validated before `execute` runs.
2674
+ * C4 — `execute` returns one canonical JSON value; `render` is a separate
2675
+ * pure text projection.
2676
+ * C6 — `exec.signal.throwIfAborted()` before any fs work.
2677
+ * C10 — no UI/transport vocabulary in the canonical value.
2678
+ */
2679
+ /**
2680
+ * Per-session queue of open requests plus the connected sidebar views.
2681
+ *
2682
+ * Lifecycle: `enqueue` adds a request and — when at least one view for the
2683
+ * session is attached — pushes it immediately and removes it from the queue
2684
+ * (consume-on-send: a reconnect must never replay an open the client already
2685
+ * applied, and the browser tab type has no per-URL dedupe, so replaying
2686
+ * would mint duplicate tabs). With no attached view the request stays queued
2687
+ * and `attach` replays it on connect. `drainAll` drops every queued request
2688
+ * (the feature was turned off); `dispose` also drops every subscriber.
2689
+ */
2690
+ var AgentOpenRegistry = class {
2691
+ pending = /* @__PURE__ */ new Map();
2692
+ subscribers = /* @__PURE__ */ new Map();
2693
+ /** Queue one open and deliver it immediately when a view is attached.
2694
+ * @returns the request id and whether a connected view received it now. */
2695
+ enqueue(sessionId, kind, target, title) {
2696
+ const request = {
2697
+ id: randomUUID(),
2698
+ sessionId,
2699
+ kind,
2700
+ target,
2701
+ title
2702
+ };
2703
+ const list = this.pending.get(sessionId) ?? [];
2704
+ list.push(request);
2705
+ this.pending.set(sessionId, list);
2706
+ const views = this.subscribers.get(sessionId);
2707
+ if (views !== void 0 && views.size > 0) {
2708
+ for (const send of views) send(request);
2709
+ this.pending.delete(sessionId);
2710
+ return {
2711
+ id: request.id,
2712
+ delivered: true
2713
+ };
2714
+ }
2715
+ return {
2716
+ id: request.id,
2717
+ delivered: false
2718
+ };
2719
+ }
2720
+ /** Attach one sidebar view (replays queued requests; consume-on-send).
2721
+ * @returns the disposer detaching the view. */
2722
+ attach(sessionId, send) {
2723
+ let views = this.subscribers.get(sessionId);
2724
+ if (views === void 0) {
2725
+ views = /* @__PURE__ */ new Set();
2726
+ this.subscribers.set(sessionId, views);
2727
+ }
2728
+ views.add(send);
2729
+ const queued = this.pending.get(sessionId) ?? [];
2730
+ if (queued.length > 0) {
2731
+ for (const request of queued) send(request);
2732
+ this.pending.delete(sessionId);
2733
+ }
2734
+ return () => {
2735
+ const current = this.subscribers.get(sessionId);
2736
+ current?.delete(send);
2737
+ if (current !== void 0 && current.size === 0) this.subscribers.delete(sessionId);
2738
+ };
2739
+ }
2740
+ /** Drop every queued request (the feature was turned off mid-session). */
2741
+ drainAll() {
2742
+ this.pending.clear();
2743
+ }
2744
+ /** Drop the queue and every subscriber (plugin teardown). */
2745
+ dispose() {
2746
+ this.pending.clear();
2747
+ this.subscribers.clear();
2748
+ }
2749
+ };
2750
+ /** Extract the calling agent or throw the canonical "no agent" error. */
2751
+ function requireAgent(agent) {
2752
+ if (agent === void 0) throw new Error("sidebar_open requires an initiating agent");
2753
+ return agent;
2754
+ }
2755
+ /** Resolve the calling agent's session id (the queue scope + ownership key). */
2756
+ function sessionIdOf(exec) {
2757
+ return requireAgent(exec.agent).session.id;
2758
+ }
2759
+ /** Pure text projection helper (the canonical value is already structured). */
2760
+ function textRender(fn) {
2761
+ return (_args, value) => [{
2762
+ type: "text",
2763
+ text: fn(value)
2764
+ }];
2765
+ }
2766
+ /** Classify a raw target: http(s) URL or a local path (stat-driven). */
2767
+ async function classifyTarget(raw, cwd) {
2768
+ if (/^https?:\/\//i.test(raw)) {
2769
+ let parsed;
2770
+ try {
2771
+ parsed = new URL(raw);
2772
+ } catch {
2773
+ throw new Error(`"${raw}" is not a valid URL`);
2774
+ }
2775
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("sidebar_open only accepts http:// and https:// URLs");
2776
+ return {
2777
+ kind: "url",
2778
+ target: raw,
2779
+ title: parsed.hostname !== "" ? parsed.hostname : raw
2780
+ };
2781
+ }
2782
+ if (!isWindowsDrivePrefix(raw) && /^[a-z][a-z0-9+.-]*:/i.test(raw)) throw new Error("sidebar_open only accepts http:// and https:// URLs; use a local path for files");
2783
+ const target = resolve(isAbsolute(raw) ? raw : join(cwd, raw));
2784
+ let info;
2785
+ try {
2786
+ info = await stat(target);
2787
+ } catch (error) {
2788
+ const code = error.code;
2789
+ if (code === "ENOENT") throw new Error(`"${raw}" does not exist (resolved to "${target}")`);
2790
+ if (code === "EACCES" || code === "EPERM") throw new Error(`"${target}" is not readable`);
2791
+ throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`);
2792
+ }
2793
+ const title = basenameOf(target);
2794
+ return {
2795
+ kind: info.isDirectory() ? "folder" : "file",
2796
+ target,
2797
+ title: title === "" ? raw : title
2798
+ };
2799
+ }
2800
+ /** The last path segment (mirror of the client's FileTree baseName). */
2801
+ function basenameOf(path) {
2802
+ const trimmed = path.replace(/[\\/]+$/, "");
2803
+ const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
2804
+ return at === -1 ? trimmed : trimmed.slice(at + 1);
2805
+ }
2806
+ /** Whether a raw target starts with a Windows drive prefix (`C:\` / `C:/`). */
2807
+ function isWindowsDrivePrefix(raw) {
2808
+ return /^[a-zA-Z]:[\\/]/.test(raw);
2809
+ }
2810
+ /**
2811
+ * Register the `sidebar_open` tool against the host tool registry. The tool
2812
+ * is gated by the side-card setting `agentOpenTools` (the caller registers
2813
+ * and unregisters it); `readPrefs` supplies the live prefs so a disabled
2814
+ * target tab type (editor/browser) is reported to the model instead of
2815
+ * silently no-oping on the client. `resolveCwd` threads the calling
2816
+ * session's live cwd so relative paths resolve the same way the sidebar's
2817
+ * own routes do.
2818
+ * @param ctx - host plugin context (carries the tools service).
2819
+ * @param registry - the open-request registry (per-session queue + views).
2820
+ * @param resolveCwd - async cwd resolver for one session id. Resolves through
2821
+ * the session header, the client-supplied cwd, and the persistence index
2822
+ * before falling back to the host process cwd (production always provides
2823
+ * persistence, so the fallback is reached only in tests / stripped-down hosts).
2824
+ * @param readPrefs - live resolved side card prefs (for tab enable gates).
2825
+ * @returns a disposer that unregisters the tool.
2826
+ */
2827
+ function registerOpenTool(ctx, registry, resolveCwd, readPrefs) {
2828
+ return ctx.tools.register(defineTool({
2829
+ name: "sidebar_open",
2830
+ description: "Open a local file, a local folder, or an HTTP(S) page in the sidebar of the calling conversation. A file opens in the sidebar editor (per-path dedupe: an already-open file is focused); a folder opens a file window whose tree is rooted at that folder; a URL opens in the sidebar browser (sandboxed iframe). The panel auto-expands for content opens and the tab title defaults to the file/folder name or the URL hostname. The path may be absolute or relative to the session working directory. The open lands in the CALLING session's sidebar: while that session's sidebar view is not connected (e.g. the session is not the active one), the open is queued and delivered when the session sidebar is next shown — the result reports `delivered` so you know whether it is visible right now. The side card setting \"model opens files/folders/pages in the sidebar\" must be on, and the target tab type must be enabled in that session's settings.",
2831
+ parameters: {
2832
+ target: {
2833
+ type: "string",
2834
+ required: true,
2835
+ description: "Absolute or session-cwd-relative local path, or an http:// / https:// URL."
2836
+ },
2837
+ title: {
2838
+ type: "string",
2839
+ description: "Optional tab title (defaults to the file/folder name or the URL hostname)."
2840
+ }
2841
+ },
2842
+ output: {
2843
+ schema: {
2844
+ type: "object",
2845
+ additionalProperties: false,
2846
+ properties: {
2847
+ kind: {
2848
+ type: "string",
2849
+ required: true,
2850
+ description: "What was opened: file | folder | url."
2851
+ },
2852
+ target: {
2853
+ type: "string",
2854
+ required: true,
2855
+ description: "The absolute path or URL the open was requested for."
2856
+ },
2857
+ title: {
2858
+ type: "string",
2859
+ required: true,
2860
+ description: "The tab title used (provided title, basename, or hostname)."
2861
+ },
2862
+ delivered: {
2863
+ type: "boolean",
2864
+ required: true,
2865
+ description: "Whether the open was pushed to a connected sidebar at call time (false = queued until the session sidebar is next shown)."
2866
+ }
2867
+ }
2868
+ },
2869
+ render: textRender((v) => v.delivered ? `Opened ${v.kind} "${v.title}" (${v.target}) in the sidebar.` : `Requested opening ${v.kind} "${v.title}" (${v.target}) in the sidebar — the session sidebar is not connected yet, so the open is queued and will appear when it is next shown.`)
2870
+ },
2871
+ execute: async (args, exec) => {
2872
+ exec.signal.throwIfAborted();
2873
+ const sessionId = sessionIdOf(exec);
2874
+ const cwd = await resolveCwd(sessionId);
2875
+ const { kind, target, title: defaultTitle } = await classifyTarget(args.target, cwd);
2876
+ const prefs = readPrefs();
2877
+ const tab = kind === "url" ? "browser" : "editor";
2878
+ if (prefs.tabsEnabled[tab] === false) throw new Error(`the built-in ${tab} tab is disabled in the side card settings; ask the user to enable it (or disable this tool)`);
2879
+ const title = args.title !== void 0 && args.title.trim() !== "" ? args.title : defaultTitle;
2880
+ const { delivered } = registry.enqueue(sessionId, kind, target, title);
2881
+ return {
2882
+ kind,
2883
+ target,
2884
+ title,
2885
+ delivered
2886
+ };
2887
+ }
2888
+ }));
2889
+ }
2890
+ //#endregion
2891
+ //#region src/jobs-routes.ts
2892
+ /**
2893
+ * Extract the plain text of a finalized tool result: the text blocks inside
2894
+ * the 'tool-result' block, joined with newlines. Error results and
2895
+ * non-text blocks contribute nothing.
2896
+ */
2897
+ function resultText(message) {
2898
+ if (!Array.isArray(message.content)) return void 0;
2899
+ const parts = [];
2900
+ for (const block of message.content) {
2901
+ if (block === null || typeof block !== "object") continue;
2902
+ const candidate = block;
2903
+ if (candidate.type !== "tool-result") continue;
2904
+ const inner = candidate.content;
2905
+ if (!Array.isArray(inner)) continue;
2906
+ for (const item of inner) {
2907
+ if (item === null || typeof item !== "object") continue;
2908
+ const textItem = item;
2909
+ if (textItem.type === "text" && typeof textItem.text === "string") parts.push(textItem.text);
2910
+ }
2911
+ }
2912
+ return parts.length > 0 ? parts.join("\n") : void 0;
2913
+ }
2914
+ /** Whether a tool/result is an error result (the inner block's isError flag). */
2915
+ function resultIsError(message) {
2916
+ if (!Array.isArray(message.content)) return false;
2917
+ return message.content.some((block) => {
2918
+ if (block === null || typeof block !== "object") return false;
2919
+ return block.type === "tool-result" && block.isError === true;
2920
+ });
2921
+ }
2922
+ /** Whether a job_output result carries no new output — the controller's
2923
+ * model-facing "(no new output)" body, noise for the human pane. */
2924
+ function isNoNewOutput(text) {
2925
+ return text.startsWith("(no new output)");
2926
+ }
2927
+ /** Extract the job_output trace of one raw session event (undefined = unrelated). */
2928
+ function traceOf(event) {
2929
+ if (event.type === "tool/call") {
2930
+ const data = event.data;
2931
+ if (data.name !== "job_output" || typeof data.callId !== "string") return void 0;
2932
+ let jobId;
2933
+ try {
2934
+ const args = JSON.parse(typeof data.arguments === "string" ? data.arguments : "");
2935
+ if (typeof args.job_id === "string") jobId = args.job_id;
2936
+ } catch {}
2937
+ if (jobId === void 0) return void 0;
2938
+ return {
2939
+ seq: event.seq,
2940
+ kind: "call",
2941
+ callId: data.callId,
2942
+ jobId
2943
+ };
2944
+ }
2945
+ if (event.type === "tool/result") {
2946
+ const message = event.data.message;
2947
+ if (message === void 0) return void 0;
2948
+ const callId = message.source?.callId;
2949
+ if (typeof callId !== "string") return void 0;
2950
+ return {
2951
+ seq: event.seq,
2952
+ kind: "result",
2953
+ callId,
2954
+ text: resultText(message),
2955
+ isError: resultIsError(message)
2956
+ };
2957
+ }
2958
+ }
2959
+ /** Per-session cap of mirrored live traces (a bounded, lossy ring). */
2960
+ const MIRROR_MAX_ENTRIES = 200;
2961
+ /**
2962
+ * The live job_output mirror: subscribes to the session append feed and
2963
+ * caches the job_output traces the session store's own log can lag behind
2964
+ * (after a host restart the store session stays frozen at its rehydration
2965
+ * boundary, so `session.events` misses everything appended since — the very
2966
+ * reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
2967
+ * same feed to browsers.
2968
+ */
2969
+ function createJobOutputMirror(ctx) {
2970
+ const perSession = /* @__PURE__ */ new Map();
2971
+ const callIds = /* @__PURE__ */ new Map();
2972
+ if (typeof ctx.on !== "function") return { entries: () => [] };
2973
+ const dispose = ctx.on("session/event", (session, event) => {
2974
+ const sessionId = session?.id;
2975
+ if (typeof sessionId !== "string") return;
2976
+ if (event.type === "tool/call") {
2977
+ const trace = traceOf(event);
2978
+ if (trace?.kind !== "call") return;
2979
+ let ids = callIds.get(sessionId);
2980
+ if (ids === void 0) callIds.set(sessionId, ids = /* @__PURE__ */ new Set());
2981
+ ids.add(trace.callId);
2982
+ push(sessionId, trace);
2983
+ } else if (event.type === "tool/result") {
2984
+ const trace = traceOf(event);
2985
+ if (trace?.kind !== "result") return;
2986
+ if (!callIds.get(sessionId)?.has(trace.callId)) return;
2987
+ push(sessionId, trace);
2988
+ }
2989
+ });
2990
+ ctx.effect(() => dispose, "dsh-coding-sidebar: job-output event mirror");
2991
+ const push = (sessionId, trace) => {
2992
+ let list = perSession.get(sessionId);
2993
+ if (list === void 0) perSession.set(sessionId, list = []);
2994
+ list.push(trace);
2995
+ if (list.length > MIRROR_MAX_ENTRIES) {
2996
+ const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES);
2997
+ const ids = callIds.get(sessionId);
2998
+ if (ids !== void 0) {
2999
+ for (const entry of removed) if (entry.kind === "call") ids.delete(entry.callId);
3000
+ if (ids.size === 0) callIds.delete(sessionId);
3001
+ }
3002
+ }
3003
+ };
3004
+ return { entries: (sessionId) => perSession.get(sessionId) ?? [] };
3005
+ }
3006
+ /**
3007
+ * Build the jobs routes bound to the plugin context. `output` merges the
3008
+ * owner session's own event log with the live job_output mirror; `kill`
3009
+ * reads the jobs/agents services lazily and degrades to a 503 when the
3010
+ * deployment lacks the registry.
3011
+ * @param ctx - host plugin context.
3012
+ * @param outputLimit - response cap for one output replay in bytes; longer
3013
+ * texts are sliced and flagged `truncated` (mirrors the fs.read cap).
3014
+ */
3015
+ function buildJobsApi(ctx, outputLimit) {
3016
+ const jobs = ctx.get("jobs");
3017
+ const agents = ctx.get("agents");
3018
+ const mirror = createJobOutputMirror(ctx);
3019
+ /** The live caller whose session id the registry fence compares against. */
3020
+ const callerOf = (sessionId) => agents?.get(sessionId);
3021
+ /** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
3022
+ const registryError = (error) => new SidebarError("job-error", error instanceof Error ? error.message : String(error), 404);
3023
+ return {
3024
+ output(payload) {
3025
+ const sessionId = requireString(payload, "sessionId");
3026
+ const id = requireString(payload, "id");
3027
+ const bySeq = /* @__PURE__ */ new Map();
3028
+ for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
3029
+ const trace = traceOf(event);
3030
+ if (trace !== void 0) bySeq.set(trace.seq, trace);
3031
+ }
3032
+ for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace);
3033
+ const jobOf = /* @__PURE__ */ new Map();
3034
+ const parts = [];
3035
+ let read = false;
3036
+ for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) if (trace.kind === "call") {
3037
+ if (trace.jobId !== void 0) jobOf.set(trace.callId, trace.jobId);
3038
+ } else if (jobOf.get(trace.callId) === id) {
3039
+ read = true;
3040
+ if (trace.isError !== true && trace.text !== void 0 && !isNoNewOutput(trace.text)) parts.push(trace.text);
3041
+ }
3042
+ const text = parts.join("\n");
3043
+ return {
3044
+ text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
3045
+ truncated: text.length > outputLimit,
3046
+ read
3047
+ };
3048
+ },
3049
+ kill(payload) {
3050
+ if (jobs === void 0) throw new SidebarError("job-error", "the background-job registry is not mounted in this deployment", 503);
3051
+ const sessionId = requireString(payload, "sessionId");
3052
+ const id = requireString(payload, "id");
3053
+ const record = payload;
3054
+ const reason = typeof record?.reason === "string" && record.reason !== "" ? record.reason : "user requested via sidebar";
3055
+ try {
3056
+ return {
3057
+ ok: true,
3058
+ outcome: jobs.kill(id, callerOf(sessionId), reason)
3059
+ };
3060
+ } catch (error) {
3061
+ throw registryError(error);
3062
+ }
3063
+ }
3064
+ };
3065
+ }
3066
+ //#endregion
3067
+ //#region src/sidechat-core.ts
3068
+ /** The durable thread-label prefix (also the row filter in the client list). */
3069
+ const SIDE_LABEL_PREFIX = "Side: ";
3070
+ /** The pinned label of a freshly created thread that no prompt has reached
3071
+ * yet (Codex-style immediate create: the tab opens an EMPTY thread, the
3072
+ * first composer message carries the boundary and earns the real label).
3073
+ * The client renders it localized; the prefix keeps the row filter honest. */
3074
+ const SIDE_NEW_THREAD_TITLE = "Side: New thread";
3075
+ /** The plugin identity stamped on the source of context-injection messages
3076
+ * (boundary prompt + parked snapshot), so the transcript recognizes them
3077
+ * structurally — not by text prefix. */
3078
+ const SIDE_INJECTION_PLUGIN = "dsh-coding-sidebar";
3079
+ /**
3080
+ * The boundary prompt delivered as the thread's first user message: the
3081
+ * inherited seed is reference context only, never active instruction.
3082
+ * Model-facing contract — change only with intent, tests pin the sentences.
3083
+ */
3084
+ const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
3085
+
3086
+ Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
3087
+
3088
+ Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
3089
+
3090
+ Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`;
3091
+ /** The data record of one event (narrowed from the loose face). */
3092
+ function dataOf(event) {
3093
+ return event.data;
3094
+ }
3095
+ /** Copy parent events verbatim (their live seq === array index contract).
3096
+ * The FULL envelope is preserved — stripping `surfaceOp` would make the
3097
+ * seed validator reject every surface-eligible message event. */
3098
+ function copyEvents(events) {
3099
+ return events.map((event) => {
3100
+ const source = event;
3101
+ return {
3102
+ type: source.type,
3103
+ seq: source.seq,
3104
+ time: source.time,
3105
+ data: dataOf(source),
3106
+ ...source.surfaceOp === void 0 ? {} : { surfaceOp: source.surfaceOp },
3107
+ ...source.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: source.sourceEventSeqs },
3108
+ ...source.ignorable === void 0 ? {} : { ignorable: source.ignorable }
3109
+ };
3110
+ });
3111
+ }
3112
+ /** Index of the last `turn/start` or `turn/end`, or -1. */
3113
+ function lastTurnBoundary(events) {
3114
+ for (let index = events.length - 1; index >= 0; index--) {
3115
+ const type = events[index]?.type;
3116
+ if (type === "turn/start" || type === "turn/end") return index;
3117
+ }
3118
+ return -1;
3119
+ }
3120
+ /** Numeric field of an event's data (turn / step numbers). */
3121
+ function numberAt(data, key) {
3122
+ const value = data[key];
3123
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : 0;
3124
+ }
3125
+ /** The step number still open at the log tail inside the turn starting at
3126
+ * `turnStart` (undefined when no step is open). */
3127
+ function openStepInTurn(events, turnStart) {
3128
+ let open;
3129
+ for (let index = turnStart + 1; index < events.length; index++) {
3130
+ const event = events[index];
3131
+ if (event === void 0) continue;
3132
+ if (event.type === "step/start") open = numberAt(dataOf(event), "step");
3133
+ else if (event.type === "step/end") open = void 0;
3134
+ }
3135
+ return open;
3136
+ }
3137
+ /**
3138
+ * Whether the open turn ending the log has a `tool/call` without its paired
3139
+ * `tool/result` in the CURRENT open step. Providers reject dangling
3140
+ * assistant calls, so such a turn cannot be honestly closed and the
3141
+ * inheritance must fall back to the snapshot.
3142
+ */
3143
+ function hasDanglingToolCall(events, turnStart) {
3144
+ const pending = /* @__PURE__ */ new Set();
3145
+ for (let index = turnStart + 1; index < events.length; index++) {
3146
+ const event = events[index];
3147
+ if (event === void 0) continue;
3148
+ const data = dataOf(event);
3149
+ if (event.type === "step/end") {
3150
+ pending.clear();
3151
+ continue;
3152
+ }
3153
+ if (event.type === "tool/call") {
3154
+ const callId = data.callId;
3155
+ if (typeof callId === "string") pending.add(callId);
3156
+ continue;
3157
+ }
3158
+ if (event.type === "tool/result") {
3159
+ const callId = data.message?.source?.callId;
3160
+ if (typeof callId === "string") pending.delete(callId);
3161
+ }
3162
+ }
3163
+ return pending.size > 0;
3164
+ }
3165
+ /** The plain text of one tool/result message (text blocks inside its
3166
+ * `tool-result` content block). */
3167
+ function toolResultText(data) {
3168
+ const content = data.message?.content;
3169
+ if (!Array.isArray(content)) return "";
3170
+ const parts = [];
3171
+ for (const block of content) {
3172
+ if (block === null || typeof block !== "object") continue;
3173
+ const candidate = block;
3174
+ if (candidate.type !== "tool-result") continue;
3175
+ const inner = candidate.content;
3176
+ if (!Array.isArray(inner)) continue;
3177
+ for (const item of inner) {
3178
+ if (item === null || typeof item !== "object") continue;
3179
+ const textItem = item;
3180
+ if (textItem.type === "text" && typeof textItem.text === "string") parts.push(textItem.text);
3181
+ }
3182
+ }
3183
+ return parts.join("\n");
3184
+ }
3185
+ /** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
3186
+ const SNAPSHOT_RESULT_CAP = 2e3;
3187
+ /** Cap applied to the whole snapshot (prompt budget). */
3188
+ const SNAPSHOT_TOTAL_CAP = 8e3;
3189
+ /**
3190
+ * Build the side-thread inheritance for one parent log: the full event log
3191
+ * up to the click moment, honestly closed when it ends inside an open turn.
3192
+ */
3193
+ function buildSidechatInheritance(events) {
3194
+ if (events.length === 0) return {
3195
+ seed: [],
3196
+ snapshot: null
3197
+ };
3198
+ const boundary = lastTurnBoundary(events);
3199
+ if (boundary < 0 || events[boundary]?.type === "turn/end") return {
3200
+ seed: copyEvents(events),
3201
+ snapshot: null
3202
+ };
3203
+ if (hasDanglingToolCall(events, boundary)) return {
3204
+ seed: copyEvents(events.slice(0, boundary)),
3205
+ snapshot: buildOpenTurnSnapshot(events)
3206
+ };
3207
+ const seed = copyEvents(events);
3208
+ const last = events[events.length - 1];
3209
+ const turn = numberAt(dataOf(events[boundary]), "turn");
3210
+ const now = last?.time ?? 0;
3211
+ const openStep = openStepInTurn(events, boundary);
3212
+ if (openStep !== void 0) seed.push({
3213
+ type: "step/end",
3214
+ seq: seed.length,
3215
+ time: now,
3216
+ data: {
3217
+ turn,
3218
+ step: openStep
3219
+ }
3220
+ });
3221
+ seed.push({
3222
+ type: "turn/end",
3223
+ seq: seed.length,
3224
+ time: now,
3225
+ data: {
3226
+ turn,
3227
+ reason: { kind: "interrupted" }
3228
+ }
3229
+ });
3230
+ return {
3231
+ seed,
3232
+ snapshot: null
3233
+ };
3234
+ }
3235
+ /**
3236
+ * Structured text snapshot of the parent's OPEN turn (from its `turn/start`
3237
+ * to the log tail): the accumulated assistant/reasoning output verbatim
3238
+ * (code blocks ride the raw deltas) and the tool activity — executed tools
3239
+ * with their result text, the still-executing one marked. Returns null when
3240
+ * there is no open turn or nothing to show.
3241
+ */
3242
+ function buildOpenTurnSnapshot(events) {
3243
+ const boundary = lastTurnBoundary(events);
3244
+ if (boundary < 0 || events[boundary]?.type !== "turn/start") return null;
3245
+ let text = "";
3246
+ let reasoning = "";
3247
+ const tools = [];
3248
+ const pendingCalls = /* @__PURE__ */ new Map();
3249
+ let total = 0;
3250
+ for (let index = boundary + 1; index < events.length; index++) {
3251
+ const event = events[index];
3252
+ if (event === void 0) continue;
3253
+ const data = dataOf(event);
3254
+ if (event.type === "step/end") {
3255
+ pendingCalls.clear();
3256
+ continue;
3257
+ }
3258
+ if (event.type === "assistant/chunk") {
3259
+ const chunk = data.chunk;
3260
+ if (chunk === null || typeof chunk !== "object") continue;
3261
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
3262
+ else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") reasoning += chunk.text;
3263
+ continue;
3264
+ }
3265
+ if (event.type === "tool/call") {
3266
+ const callId = data.callId;
3267
+ if (typeof callId === "string") pendingCalls.set(callId, {
3268
+ name: typeof data.name === "string" ? data.name : "tool",
3269
+ args: typeof data.arguments === "string" ? data.arguments : ""
3270
+ });
3271
+ continue;
3272
+ }
3273
+ if (event.type === "tool/result") {
3274
+ const source = data.message;
3275
+ const callId = typeof source?.source?.callId === "string" ? source.source.callId : void 0;
3276
+ const name = callId !== void 0 ? pendingCalls.get(callId)?.name : void 0;
3277
+ const args = callId !== void 0 ? pendingCalls.get(callId)?.args : void 0;
3278
+ if (callId !== void 0) pendingCalls.delete(callId);
3279
+ const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP);
3280
+ const failed = data.error !== void 0;
3281
+ const line = [`- \`${name ?? "tool"}\`${failed ? " (failed)" : ""}` + (args !== void 0 && args !== "" ? ` — arguments: \`${args}\`` : ""), ...result === "" ? [] : [` Result: ${result}`]].join("\n");
3282
+ tools.push(line);
3283
+ total += line.length;
3284
+ }
3285
+ }
3286
+ for (const [, call] of pendingCalls) {
3287
+ const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``;
3288
+ tools.push(line);
3289
+ total += line.length;
3290
+ }
3291
+ const sections = [];
3292
+ if (text.trim() !== "") sections.push(`Assistant output so far:\n\n${text}`);
3293
+ if (reasoning.trim() !== "") sections.push(`Reasoning so far:\n\n${reasoning}`);
3294
+ if (tools.length > 0) sections.push(`Tool activity:\n${tools.join("\n")}`);
3295
+ if (sections.length === 0) return null;
3296
+ const body = sections.join("\n\n");
3297
+ return body.length > SNAPSHOT_TOTAL_CAP ? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…` : `Parent session in-progress turn (reference only):\n\n${body}`;
3298
+ }
3299
+ /** Truncate + prefix a question into a durable thread label. */
3300
+ function sideLabel(question) {
3301
+ const flat = question.replace(/\s+/g, " ").trim();
3302
+ const max = Math.max(1, 42);
3303
+ const body = flat.length > max ? `${flat.slice(0, 41)}…` : flat;
3304
+ return `${SIDE_LABEL_PREFIX}${body}`;
3305
+ }
3306
+ /**
3307
+ * Whether the thread log already carries the side boundary message — i.e.
3308
+ * the first prompt was delivered. Tolerant to the content shape (block
3309
+ * array or bare string) and to inherited seed messages (only an OWN
3310
+ * boundary message starts with the prefix; seed messages came from the
3311
+ * parent's log, which never contains one).
3312
+ */
3313
+ function boundaryDelivered(events) {
3314
+ for (const event of events) {
3315
+ if (event.type !== "user/message") continue;
3316
+ if (messageLeadText(dataOf(event)).startsWith("Side conversation boundary")) return true;
3317
+ }
3318
+ return false;
3319
+ }
3320
+ /** The leading text of a user/message's content (block array or bare string). */
3321
+ function messageLeadText(data) {
3322
+ const content = data.content;
3323
+ const first = Array.isArray(content) ? content[0] : content;
3324
+ return typeof first === "string" ? first : typeof first === "object" && first !== null && "text" in first ? String(first.text) : "";
3325
+ }
3326
+ /**
3327
+ * The agent preset a session actually runs: newest `agent-preset/selected`
3328
+ * event wins, else the creation header (mirror of the dsh-agent-presets
3329
+ * resolveSessionPreset helper — replicated here to avoid a host dependency
3330
+ * on that package).
3331
+ */
3332
+ function resolvePresetId(header, events) {
3333
+ for (let index = events.length - 1; index >= 0; index--) {
3334
+ const event = events[index];
3335
+ if (event?.type !== "agent-preset/selected") continue;
3336
+ const preset = dataOf(event).agentPreset;
3337
+ if (typeof preset === "string") return preset;
3338
+ }
3339
+ return header.agentPreset;
3340
+ }
3341
+ //#endregion
3342
+ //#region src/subagent-activity.ts
3343
+ /**
3344
+ * Extract the concatenated plain text of a content-block list (the durable
3345
+ * `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
3346
+ * `text`; anything else — tool_use, image, … — contributes nothing).
3347
+ * @param content - the raw `content` field of a message event.
3348
+ * @returns the joined text, or undefined when the message carries no text.
3349
+ */
3350
+ function contentText(content) {
3351
+ if (!Array.isArray(content)) return void 0;
3352
+ const parts = [];
3353
+ for (const block of content) {
3354
+ if (block === null || typeof block !== "object") continue;
3355
+ const candidate = block;
3356
+ if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
3357
+ }
3358
+ return parts.length > 0 ? parts.join("\n") : void 0;
3359
+ }
3360
+ /**
3361
+ * Fold a session event log into the last text output + last tool call (each
3362
+ * is the LAST occurrence in event order). Lifecycle events and raw
3363
+ * `assistant/chunk` rows are ignored — the card shows what the subagent is
3364
+ * doing right now, not its plumbing. The scan runs BACKWARD from the newest
3365
+ * event and stops once both fields are found, so a long history costs only
3366
+ * the recent tail in the common case.
3367
+ * @param events - the session's append-only event log (oldest → newest).
3368
+ * @param maxMessages - optional message-boundary window: only the tail's
3369
+ * last `maxMessages` surface messages (`user/message`, `assistant/message`)
3370
+ * and the events between them are considered, mirroring the old
3371
+ * `subagents.history({ maxMessages })` window. Stale activity older than
3372
+ * the window is never surfaced, and a long log is never scanned in full.
3373
+ * @returns the last text and/or tool call; an empty object when the log has neither.
3374
+ */
3375
+ function lastActivity(events, maxMessages = Infinity) {
3376
+ let text;
3377
+ let tool;
3378
+ let messagesSeen = 0;
3379
+ for (let index = events.length - 1; index >= 0; index -= 1) {
3380
+ if (text !== void 0 && tool !== void 0) break;
3381
+ const event = events[index];
3382
+ if (event === void 0) continue;
3383
+ const { type, data } = event;
3384
+ if (type === "user/message" || type === "assistant/message") {
3385
+ messagesSeen += 1;
3386
+ if (messagesSeen > maxMessages) break;
3387
+ } else if (messagesSeen >= maxMessages) continue;
3388
+ if (text === void 0 && type === "assistant/message") {
3389
+ const message = data.message;
3390
+ const extracted = contentText(message?.content);
3391
+ if (extracted !== void 0) text = extracted;
3392
+ } else if (tool === void 0 && type === "tool/call") tool = {
3393
+ name: typeof data.name === "string" ? data.name : "tool",
3394
+ args: typeof data.arguments === "string" ? data.arguments : ""
3395
+ };
3396
+ }
3397
+ if (text === void 0 && tool === void 0) return {};
3398
+ return {
3399
+ ...text === void 0 ? {} : { text },
3400
+ ...tool === void 0 ? {} : { tool }
3401
+ };
3402
+ }
3403
+ /**
3404
+ * Build the live-preview routes bound to the plugin context.
3405
+ * @param ctx - host plugin context.
3406
+ */
3407
+ function buildSubagentLiveApi(ctx) {
3408
+ return { async live(payload) {
3409
+ const rootSessionId = requireString(payload, "rootSessionId");
3410
+ const subagents = ctx.get("subagents");
3411
+ if (subagents === void 0 || typeof subagents.listDescendants !== "function") throw new SidebarError("subagents-unavailable", "the subagent service is not mounted in this deployment", 503);
3412
+ let descendants;
3413
+ try {
3414
+ descendants = await subagents.listDescendants(rootSessionId);
3415
+ } catch (error) {
3416
+ throw new SidebarError("subagents-unavailable", `subagent catalog read failed: ${error instanceof Error ? error.message : String(error)}`, 503);
3417
+ }
3418
+ const live = {};
3419
+ for (const entry of descendants) {
3420
+ if (entry.kind !== "child" || entry.activity !== "running") continue;
3421
+ if (entry.label?.startsWith("Side: ") ?? false) continue;
3422
+ try {
3423
+ const activity = lastActivity(ctx.sessions.get(entry.id)?.events ?? [], 12);
3424
+ if (activity.text !== void 0 || activity.tool !== void 0) live[entry.id] = activity;
3425
+ } catch {}
3426
+ }
3427
+ return { live };
3428
+ } };
3429
+ }
3430
+ //#endregion
3431
+ //#region src/sidechat-routes.ts
3432
+ /**
3433
+ * Side Chat routes of the /sidebar JSON API ('sidechat.start' /
3434
+ * 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
3435
+ *
3436
+ * A side thread is a child session the plugin creates ITSELF with a custom
3437
+ * seed — the parent's full event log up to the click moment, honestly closed
3438
+ * at an in-progress turn (see sidechat-core.ts). The child is marked
3439
+ * `origin: 'subagent'` so the main session list hides it, and EVERY
3440
+ * operation goes through these routes because the generic session RPCs are
3441
+ * fenced away from subagent-origin identities (the api-remotes
3442
+ * agent-lookup ownership fence). No DSH source is touched:
3443
+ *
3444
+ * - creation uses the public AgentRegistry.create seam (the same one
3445
+ * api-proxy's session.fork and the subagent fork provider use), with the
3446
+ * parent's preset composition and provider/model selection so the child's
3447
+ * first request shares the parent's token prefix (provider-side prefix
3448
+ * cache reuse);
3449
+ * - the first prompt (boundary + question) and every follow-up are admitted
3450
+ * with the stock `agent.followup`;
3451
+ * - a cold thread (DSH restart, or a closed thread) is resumed with
3452
+ * AgentRegistry.resume, composing the preset the child recorded.
3453
+ */
3454
+ /** Timeout guarding the create call (the registry detaches it before the
3455
+ * handle becomes visible, so the child is never cancelled by it). */
3456
+ const CREATE_TIMEOUT_MS = 15e3;
3457
+ /** Per-activation disposers of created thread agents (the dispose route
3458
+ * releases them; the session and its history always stay persisted). */
3459
+ const threadDisposers = /* @__PURE__ */ new Map();
3460
+ /** The in-progress-turn snapshot captured at creation of an EMPTY thread,
3461
+ * waiting to ride the first prompt (lost on a host restart — the boundary
3462
+ * prompt is then delivered alone, a logged degradation). */
3463
+ const pendingSnapshots = /* @__PURE__ */ new Map();
3464
+ /** Resolve the parent's preset and build the child's composition setup
3465
+ * (mirror of api-proxy's composeAgent minus the model-selection install —
3466
+ * the child carries the parent's provider/model in agentOptions). */
3467
+ async function composeChildSetup(ctx, presetId) {
3468
+ const presets = ctx.get("agentPresets");
3469
+ if (presets === void 0) return { setup: () => Promise.resolve() };
3470
+ const resolved = await presets.resolve(presetId);
3471
+ return {
3472
+ agentPreset: resolved.id,
3473
+ setup: async (agentCtx) => {
3474
+ await presets.mount(agentCtx, resolved.id);
3475
+ }
3476
+ };
3477
+ }
3478
+ /** Build the cold-resume setup from the thread's PERSISTED record (the
3479
+ * recorded preset wins, newest selection event first). */
3480
+ async function composePersistedSetup(ctx, childId) {
3481
+ const persistence = ctx.get("sessionPersistence");
3482
+ if (persistence === void 0) return () => Promise.resolve();
3483
+ const inspected = await persistence.inspect(childId);
3484
+ const presetId = resolvePresetId(inspected.meta, inspected.events);
3485
+ const presets = ctx.get("agentPresets");
3486
+ if (presets === void 0 || presetId === void 0) return () => Promise.resolve();
3487
+ const resolved = await presets.resolve(presetId);
3488
+ return async (agentCtx) => {
3489
+ await presets.mount(agentCtx, resolved.id);
3490
+ };
3491
+ }
3492
+ /** One text-block prompt (the thread boundary + question, or a follow-up). */
3493
+ function textPrompt(text) {
3494
+ return [{
3495
+ type: "text",
3496
+ text
3497
+ }];
3498
+ }
3499
+ /** Admit one user message to a live agent through the stock followup path. */
3500
+ function admitFollowup(agent, blocks) {
3501
+ const message = createUserMessage({
3502
+ content: blocks,
3503
+ source: { kind: "user" }
3504
+ });
3505
+ agent.followup(message);
3506
+ }
3507
+ /**
3508
+ * Deliver the thread's FIRST contact as TWO log-separated messages: the
3509
+ * boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
3510
+ * — queued model-facing context that does NOT wake the driver and is
3511
+ * claimed FIRST at the opening step (Inbox.claim drains next-step before
3512
+ * next-turn) — and the user's question is the follow-up that wakes it. The
3513
+ * log therefore records two user/message events (injection, then question)
3514
+ * instead of one wrapped blob: the transcript shows the question as a user
3515
+ * bubble and collapses the injection as a context row. The injection source
3516
+ * is stamped `kind: 'plugin'` so recognition is structural; its text still
3517
+ * opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
3518
+ */
3519
+ function admitFirstContact(agent, injectionText, question) {
3520
+ agent.inject(createUserMessage({
3521
+ content: textPrompt(injectionText),
3522
+ source: {
3523
+ kind: "plugin",
3524
+ plugin: SIDE_INJECTION_PLUGIN
3525
+ }
3526
+ }));
3527
+ admitFollowup(agent, textPrompt(question));
3528
+ }
3529
+ /** The live thread agent, or undefined (cold — the caller resumes). */
3530
+ function liveThreadAgent(ctx, childId) {
3531
+ return ctx.get("agents")?.get(childId);
3532
+ }
3533
+ /** Build the Side Chat routes (all optional services degrade to a wire
3534
+ * error the tab surfaces inline). The record keys are the FULL wire method
3535
+ * names the /sidebar/api dispatcher looks up (`api[method]`). */
3536
+ function buildSidechatApi(ctx) {
3537
+ return {
3538
+ "sidechat.start": async (payload) => {
3539
+ const sessionId = requireString(payload, "sessionId");
3540
+ const rawQuestion = payload.question;
3541
+ const question = typeof rawQuestion === "string" ? rawQuestion.trim() : "";
3542
+ const parent = liveThreadAgent(ctx, sessionId);
3543
+ if (parent === void 0) throw new SidebarError("sidechat-error", `parent session "${sessionId}" is not running`, 409);
3544
+ const parentSession = parent.session;
3545
+ const inheritance = buildSidechatInheritance(parentSession.events);
3546
+ const { agentPreset, setup } = await composeChildSetup(ctx, resolvePresetId(parentSession.header, parentSession.events));
3547
+ const childId = `session-${randomUUID()}`;
3548
+ const label = question === "" ? SIDE_NEW_THREAD_TITLE : sideLabel(question);
3549
+ const descriptor = snapshotSubagentDescriptor({
3550
+ mode: "continuable",
3551
+ provider: "sidechat",
3552
+ label,
3553
+ ...parent.options.provider === void 0 ? {} : { agentProvider: parent.options.provider },
3554
+ ...parent.options.model === void 0 ? {} : { agentModel: parent.options.model }
3555
+ });
3556
+ const descriptorEvent = {
3557
+ type: "subagent/descriptor",
3558
+ seq: inheritance.seed.length,
3559
+ time: Date.now(),
3560
+ data: descriptor
3561
+ };
3562
+ const seed = [...inheritance.seed, descriptorEvent];
3563
+ const options = {
3564
+ sessionId: childId,
3565
+ meta: {
3566
+ ...parentSession.header.cwd === void 0 ? {} : { cwd: parentSession.header.cwd },
3567
+ parentSession: parentSession.id,
3568
+ seedLength: seed.length,
3569
+ origin: "subagent",
3570
+ delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
3571
+ ...agentPreset === void 0 ? {} : { agentPreset }
3572
+ },
3573
+ seed,
3574
+ agentOptions: { ...parent.options },
3575
+ setup,
3576
+ signal: AbortSignal.timeout(CREATE_TIMEOUT_MS)
3577
+ };
3578
+ const agents = ctx.get("agents");
3579
+ if (agents?.create === void 0) throw new SidebarError("sidechat-error", "the agents service is unavailable", 503);
3580
+ let handle;
3581
+ try {
3582
+ handle = await agents.create(options);
3583
+ } catch (error) {
3584
+ throw new SidebarError("sidechat-error", `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500);
3585
+ }
3586
+ threadDisposers.set(childId, () => handle.dispose());
3587
+ const titles = ctx.get("sessionTitle");
3588
+ const pinTitle = (label) => {
3589
+ if (titles === void 0) return;
3590
+ try {
3591
+ titles.rename(handle.agent.session, label);
3592
+ } catch {}
3593
+ };
3594
+ if (question === "") {
3595
+ if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot);
3596
+ pinTitle(SIDE_NEW_THREAD_TITLE);
3597
+ } else {
3598
+ const promptParts = [SIDE_BOUNDARY_PROMPT];
3599
+ if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot);
3600
+ admitFirstContact(handle.agent, promptParts.join("\n\n"), question);
3601
+ pinTitle(sideLabel(question));
3602
+ }
3603
+ return { childId };
3604
+ },
3605
+ "sidechat.prompt": async (payload) => {
3606
+ const childId = requireString(payload, "childId");
3607
+ const text = requireString(payload, "text").trim();
3608
+ if (text === "") throw new SidebarError("bad-request", "text is required");
3609
+ let agent = liveThreadAgent(ctx, childId);
3610
+ if (agent === void 0) {
3611
+ const agents = ctx.get("agents");
3612
+ if (agents?.resume === void 0) throw new SidebarError("sidechat-error", "the agents service is unavailable", 503);
3613
+ const setup = await composePersistedSetup(ctx, childId);
3614
+ try {
3615
+ const handle = await agents.resume({
3616
+ resumeSessionId: childId,
3617
+ setup
3618
+ });
3619
+ threadDisposers.set(childId, () => handle.dispose());
3620
+ agent = handle.agent;
3621
+ } catch (error) {
3622
+ throw new SidebarError("sidechat-error", `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500);
3623
+ }
3624
+ }
3625
+ if (boundaryDelivered(agent.session.events)) admitFollowup(agent, textPrompt(text));
3626
+ else {
3627
+ const parts = [SIDE_BOUNDARY_PROMPT];
3628
+ const snapshot = pendingSnapshots.get(childId);
3629
+ pendingSnapshots.delete(childId);
3630
+ if (snapshot !== void 0) parts.push(snapshot);
3631
+ admitFirstContact(agent, parts.join("\n\n"), text);
3632
+ const titles = ctx.get("sessionTitle");
3633
+ if (titles !== void 0) try {
3634
+ titles.rename(agent.session, sideLabel(text));
3635
+ } catch {}
3636
+ }
3637
+ return { accepted: true };
3638
+ },
3639
+ "sidechat.cancel": async (payload) => {
3640
+ const agent = liveThreadAgent(ctx, requireString(payload, "childId"));
3641
+ if (agent !== void 0) agent.cancel({ kind: "user" }, { keepInbox: true });
3642
+ return { accepted: true };
3643
+ },
3644
+ "sidechat.dispose": async (payload) => {
3645
+ const childId = requireString(payload, "childId");
3646
+ pendingSnapshots.delete(childId);
3647
+ const dispose = threadDisposers.get(childId);
3648
+ if (dispose !== void 0) {
3649
+ threadDisposers.delete(childId);
3650
+ try {
3651
+ await dispose();
3652
+ } catch {}
3653
+ }
3654
+ return { accepted: true };
3655
+ },
3656
+ "sidechat.info": async (payload) => {
3657
+ const childId = requireString(payload, "childId");
3658
+ const agent = liveThreadAgent(ctx, childId);
3659
+ if (agent !== void 0) {
3660
+ const preset = agent.session.header.agentPreset;
3661
+ return {
3662
+ live: true,
3663
+ status: agent.status,
3664
+ ...agent.options.provider === void 0 ? {} : { provider: agent.options.provider },
3665
+ ...agent.options.model === void 0 ? {} : { model: agent.options.model },
3666
+ ...preset === void 0 ? {} : { preset }
3667
+ };
3668
+ }
3669
+ const persistence = ctx.get("sessionPersistence");
3670
+ if (persistence !== void 0) try {
3671
+ const inspected = await persistence.inspect(childId);
3672
+ const preset = resolvePresetId(inspected.meta, inspected.events);
3673
+ return {
3674
+ live: false,
3675
+ ...preset === void 0 ? {} : { preset }
3676
+ };
3677
+ } catch {}
3678
+ return { live: false };
3679
+ }
3680
+ };
3681
+ }
3682
+ //#endregion
3683
+ //#region src/index.ts
3684
+ /**
3685
+ * dsh-coding-sidebar host half: the /sidebar JSON API (explorer listing, file
3686
+ * read/write, git), the /sidebar/file media route (images), the /sidebar/html
3687
+ * preview route, the /sidebar/bundle lazy-chunk route (client code splits),
3688
+ * and the terminal WebSocket upgrade. Every route passes the same
3689
+ * browser-trust fence as the /api gateway — Host-header loopback or the
3690
+ * web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
3691
+ * `--trusted-host` authorities), read per request from the live service
3692
+ * value so the fence tracks the same trust source the /api gateway derives
3693
+ * its list from.
3694
+ *
3695
+ * All operations are conversation-scoped: requests carry a sessionId, the
3696
+ * session's authoritative cwd comes from the session store, and terminal
3697
+ * processes are keyed by session.
3698
+ */
3699
+ /** Plugin identity for cordis.yml rows. */
3700
+ const name = "dsh-coding-sidebar";
3701
+ /** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */
3702
+ const inject = [
3703
+ "webServer",
3704
+ "sessions",
3705
+ "webRuntime",
3706
+ "tools"
3707
+ ];
3708
+ /** Content types for the media route, by extension. */
3709
+ const MEDIA_TYPES = {
3710
+ ".png": "image/png",
3711
+ ".jpg": "image/jpeg",
3712
+ ".jpeg": "image/jpeg",
3713
+ ".gif": "image/gif",
3714
+ ".webp": "image/webp",
3715
+ ".svg": "image/svg+xml",
3716
+ ".bmp": "image/bmp",
3717
+ ".ico": "image/x-icon",
3718
+ ".avif": "image/avif",
3719
+ ".pdf": "application/pdf",
3720
+ ".html": "text/html",
3721
+ ".htm": "text/html"
3722
+ };
3723
+ /** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
3724
+ function mediaTypeForPath(path) {
3725
+ return MEDIA_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
3726
+ }
3727
+ /**
3728
+ * Resolve a session's authoritative working directory. The attached session
3729
+ * header wins; while the session is still hydrating from persistence (the
3730
+ * web client attaches the current conversation a moment after page load, so
3731
+ * the very first sidebar requests can arrive detached) the caller's own
3732
+ * list-summary cwd is used; the session-persistence index is queried as a
3733
+ * last resort for cold (not-yet-attached) sessions so a detached first
3734
+ * request still resolves the correct project instead of the host process
3735
+ * cwd (which on Windows is the DSH source root after `dsh.cmd`'s `pushd`,
3736
+ * causing every user-project path to be misclassified as "outside
3737
+ * workspace"). The host process cwd is the FINAL fallback for deployments
3738
+ * without persistence (tests / stripped-down hosts); production always
3739
+ * provides persistence, so the bug-fix path (header → client → persistence)
3740
+ * always resolves the real session cwd before reaching it.
3741
+ */
3742
+ async function sessionCwdOf(ctx, sessionId, clientCwd) {
3743
+ const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
3744
+ if (headerCwd !== void 0 && headerCwd !== "") return headerCwd;
3745
+ if (clientCwd !== void 0 && clientCwd !== "") try {
3746
+ return requireAbsolute(clientCwd);
3747
+ } catch {
3748
+ throw new SidebarError("bad-request", `invalid working directory "${clientCwd}"`);
3749
+ }
3750
+ const persistence = ctx.get("sessionPersistence");
3751
+ if (persistence !== void 0) {
3752
+ const metaCwd = (await persistence.inspect(sessionId)).meta.cwd;
3753
+ if (metaCwd !== void 0 && metaCwd !== "") try {
3754
+ return requireAbsolute(metaCwd);
3755
+ } catch {
3756
+ throw new SidebarError("bad-request", `invalid working directory "${metaCwd}"`);
3757
+ }
3758
+ }
3759
+ return process.cwd();
3760
+ }
3761
+ /** Optional repository selected by the Git panel when cwd is a container. */
3762
+ function selectedRepoOf(payload) {
3763
+ if (payload.repoRoot === void 0) return void 0;
3764
+ return requireAbsolute(requireString(payload, "repoRoot"));
3765
+ }
3766
+ /**
3767
+ * Resolve a path that a git command reported — `git status`/`git diff`
3768
+ * print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
3769
+ * session cwd (a session inside a subdirectory of a repository). Absolute
3770
+ * paths pass through; relative ones join the repo root (falling back to the
3771
+ * cwd when the root cannot be resolved, e.g. a bare directory).
3772
+ */
3773
+ async function resolveGitPath(cwd, raw, selected) {
3774
+ if (isAbsolute(raw)) return requireAbsolute(raw);
3775
+ const sessionPath = requireAbsolute(join(cwd, raw));
3776
+ if (await stat(sessionPath).then(() => true).catch(() => false)) return sessionPath;
3777
+ const root = await repoRoot(cwd, selected).catch(() => cwd);
3778
+ return requireAbsolute(join(root, raw));
3779
+ }
3780
+ /** How many leading bytes a binary read returns for client-side detect sniffing. */
3781
+ const READ_HEAD_LIMIT = 4096;
3782
+ /** Text read of a file with the size cap; binary detection via NUL probe.
3783
+ * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
3784
+ * so the client can re-match viewers by content (`detect`). */
3785
+ async function readText(path, readLimit) {
3786
+ const info = await stat(path).catch((error) => {
3787
+ throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
3788
+ });
3789
+ if (info.isDirectory()) throw new SidebarError("fs-error", `"${path}" is a directory`, 400);
3790
+ const size = info.size;
3791
+ const truncated = size > readLimit;
3792
+ const handle = await open(path, "r").catch((error) => {
3793
+ throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
3794
+ });
3795
+ try {
3796
+ const buffer = Buffer.alloc(Math.min(size, readLimit));
3797
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
3798
+ const slice = buffer.subarray(0, bytesRead);
3799
+ const binary = slice.includes(0);
3800
+ const head = binary ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString("base64") : void 0;
3801
+ return {
3802
+ content: binary ? "" : slice.toString("utf8"),
3803
+ truncated,
3804
+ binary,
3805
+ size,
3806
+ head
3807
+ };
3808
+ } finally {
3809
+ await handle.close();
3810
+ }
3811
+ }
3812
+ /** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
3813
+ /**
3814
+ * Resolve the settings-page terminal shell overrides (the terminal card's
3815
+ * gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
3816
+ * `shellArgs` (or the platform auto-resolution). The settings page is the
3817
+ * runtime complement to the boot-time yaml — same contract, later binding:
3818
+ * the values here win for terminals opened afterwards.
3819
+ */
3820
+ function shellOverridesOf(getSettings) {
3821
+ const value = getSettings()?.get().value;
3822
+ if (value === null || typeof value !== "object") return {};
3823
+ const record = value;
3824
+ const shell = typeof record.terminalShell === "string" ? record.terminalShell.trim() : "";
3825
+ const args = typeof record.terminalShellArgs === "string" ? record.terminalShellArgs.trim() : "";
3826
+ return {
3827
+ shell: shell === "" ? void 0 : shell,
3828
+ shellArgs: args === "" ? void 0 : args.split(/\s+/).filter(Boolean)
3829
+ };
3830
+ }
3831
+ /**
3832
+ * Parse the browser tab's `browserAllowedLoopback` allowlist into a matcher
3833
+ * over host:port (same contract as the client-side helper in
3834
+ * src/client/browser.ts — kept in sync). Bare hosts (`localhost`,
3835
+ * `127.0.0.1`) match every port; `host:port` entries match exactly.
3836
+ */
3837
+ function parseLoopbackAllowlist(allowlist) {
3838
+ const entries = allowlist.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
3839
+ const exact = new Set(entries);
3840
+ const hosts = /* @__PURE__ */ new Set();
3841
+ for (const entry of entries) if (!entry.includes(":")) hosts.add(entry.replace(/^\[|\]$/g, ""));
3842
+ return (host, port) => {
3843
+ const key = `${host}:${port}`;
3844
+ if (exact.has(key) || exact.has(host)) return true;
3845
+ return port !== "" && hosts.has(host);
3846
+ };
3847
+ }
3848
+ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, getSettings) {
3849
+ const cwdOf = async (payload) => {
3850
+ const sessionId = requireString(payload, "sessionId");
3851
+ const record = payload;
3852
+ return {
3853
+ sessionId,
3854
+ cwd: await sessionCwdOf(ctx, sessionId, typeof record?.cwd === "string" && record.cwd !== "" ? record.cwd : void 0)
3855
+ };
3856
+ };
3857
+ /** Resolve the optional Git-panel checkout selector against the authoritative
3858
+ * session repository. Unlike `cwd`, `worktree` is never trusted directly. */
3859
+ const gitCwdOf = async (payload) => {
3860
+ const base = await cwdOf(payload);
3861
+ const record = payload;
3862
+ const requested = typeof record?.worktree === "string" && record.worktree !== "" ? record.worktree : void 0;
3863
+ return {
3864
+ sessionId: base.sessionId,
3865
+ cwd: await resolveWorktree(base.cwd, requested)
3866
+ };
3867
+ };
3868
+ const jobsApi = buildJobsApi(ctx, resolved.readLimit);
3869
+ const subagentLiveApi = buildSubagentLiveApi(ctx);
3870
+ return {
3871
+ "session.cwd": async (payload) => {
3872
+ const { sessionId, cwd } = await cwdOf(payload);
3873
+ return {
3874
+ sessionId,
3875
+ cwd,
3876
+ root: rootLabel(cwd),
3877
+ parent: parentOf(cwd) ?? null
3878
+ };
3879
+ },
3880
+ "fs.tree": async (payload) => {
3881
+ const { cwd } = await cwdOf(payload);
3882
+ return listDirectory(payload.path === void 0 ? cwd : await ensureWorkspacePath(cwd, requireString(payload, "path")), resolved.listLimit);
3883
+ },
3884
+ "fs.search": async (payload) => {
3885
+ const { cwd } = await cwdOf(payload);
3886
+ return searchFiles(cwd, requireString(payload, "query"));
3887
+ },
3888
+ "fs.read": async (payload) => {
3889
+ const { cwd } = await cwdOf(payload);
3890
+ const selected = selectedRepoOf(payload);
3891
+ const { content, truncated, binary, size, head } = await readText(await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, "path"), selected)), resolved.readLimit);
3892
+ if (binary) return {
3893
+ kind: "binary",
3894
+ size,
3895
+ truncated,
3896
+ head
3897
+ };
3898
+ return {
3899
+ kind: "text",
3900
+ content,
3901
+ truncated
3902
+ };
3903
+ },
3904
+ "fs.write": async (payload) => {
3905
+ const { cwd } = await cwdOf(payload);
3906
+ const path = await ensureWorkspaceWritePath(cwd, requireString(payload, "path"));
3907
+ const content = requireString(payload, "content");
3908
+ const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`;
3909
+ try {
3910
+ await mkdir(dirname(path), { recursive: true });
3911
+ await writeFile(tmp, content, "utf8");
3912
+ await rename(tmp, path);
3913
+ } catch (error) {
3914
+ await rm(tmp, { force: true }).catch(() => {});
3915
+ throw new SidebarError("fs-error", `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
3916
+ }
3917
+ return { ok: true };
3918
+ },
3919
+ "git.worktrees": async (payload) => {
3920
+ const { cwd } = await gitCwdOf(payload);
3921
+ const selected = selectedRepoOf(payload);
3922
+ return worktrees(selected !== void 0 ? await repoRoot(cwd, selected).catch(() => cwd) : cwd);
3923
+ },
3924
+ "git.status": async (payload) => {
3925
+ const { cwd } = await gitCwdOf(payload);
3926
+ return status(cwd, selectedRepoOf(payload));
3927
+ },
3928
+ "git.diff": async (payload) => {
3929
+ const { cwd } = await gitCwdOf(payload);
3930
+ const record = payload;
3931
+ const repoRoot = selectedRepoOf(payload);
3932
+ return { diff: await diff(cwd, record.path === void 0 ? void 0 : await resolveGitPath(cwd, requireString(payload, "path"), repoRoot), record.staged === true, repoRoot) };
3933
+ },
3934
+ "git.stage": async (payload) => {
3935
+ const { cwd } = await gitCwdOf(payload);
3936
+ await stage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"), selectedRepoOf(payload));
3937
+ return { ok: true };
3938
+ },
3939
+ "git.unstage": async (payload) => {
3940
+ const { cwd } = await gitCwdOf(payload);
3941
+ await unstage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"), selectedRepoOf(payload));
3942
+ return { ok: true };
3943
+ },
3944
+ "git.commit": async (payload) => {
3945
+ const { cwd } = await gitCwdOf(payload);
3946
+ await commit(cwd, requireString(payload, "message"), selectedRepoOf(payload));
3947
+ return { ok: true };
3948
+ },
3949
+ "git.branch": async (payload) => {
3950
+ const { cwd } = await gitCwdOf(payload);
3951
+ return branches(cwd, selectedRepoOf(payload));
3952
+ },
3953
+ "git.checkout": async (payload) => {
3954
+ const { cwd } = await gitCwdOf(payload);
3955
+ await checkout(cwd, requireString(payload, "branch"), selectedRepoOf(payload));
3956
+ return { ok: true };
3957
+ },
3958
+ "git.log": async (payload) => {
3959
+ const { cwd } = await gitCwdOf(payload);
3960
+ const record = payload;
3961
+ return log(cwd, typeof record.count === "number" && Number.isInteger(record.count) && record.count > 0 ? record.count : void 0, typeof record.skip === "number" && Number.isInteger(record.skip) && record.skip >= 0 ? record.skip : void 0, selectedRepoOf(payload));
3962
+ },
3963
+ "git.commit-diff": async (payload) => {
3964
+ const { cwd } = await gitCwdOf(payload);
3965
+ return { diff: await commitDiff(cwd, requireString(payload, "hash"), selectedRepoOf(payload)) };
3966
+ },
3967
+ "git.discard": async (payload) => {
3968
+ const { cwd } = await gitCwdOf(payload);
3969
+ const repoRoot = selectedRepoOf(payload);
3970
+ await discard(cwd, await resolveGitPath(cwd, requireString(payload, "path"), repoRoot), repoRoot);
3971
+ return { ok: true };
3972
+ },
3973
+ "git.revert": async (payload) => {
3974
+ const { cwd } = await gitCwdOf(payload);
3975
+ await revert(cwd, requireString(payload, "hash"), selectedRepoOf(payload));
3976
+ return { ok: true };
3977
+ },
3978
+ "git.cherry-pick": async (payload) => {
3979
+ const { cwd } = await gitCwdOf(payload);
3980
+ await cherryPick(cwd, requireString(payload, "hash"), selectedRepoOf(payload));
3981
+ return { ok: true };
3982
+ },
3983
+ "git.show": async (payload) => {
3984
+ const { cwd } = await gitCwdOf(payload);
3985
+ const repoRoot = selectedRepoOf(payload);
3986
+ const path = await resolveGitPath(cwd, requireString(payload, "path"), repoRoot);
3987
+ return { content: await show(cwd, requireString(payload, "rev"), path, repoRoot) };
3988
+ },
3989
+ "pty.close": (payload) => {
3990
+ const sessionId = requireString(payload, "sessionId");
3991
+ const tab = requireString(payload, "tab");
3992
+ ptyManager?.close(`${sessionId}:${tab}`);
3993
+ return { ok: true };
3994
+ },
3995
+ "agent-pty.close": (payload) => {
3996
+ const uuid = requireString(payload, "uuid");
3997
+ agentPtyRegistry?.close(uuid);
3998
+ return { ok: true };
3999
+ },
4000
+ "terminal.deps": () => depsStatus(),
4001
+ "jobs.output": (payload) => jobsApi.output(payload),
4002
+ "jobs.kill": (payload) => jobsApi.kill(payload),
4003
+ "subagents.live": (payload) => subagentLiveApi.live(payload),
4004
+ "shell.get": () => ({
4005
+ shell: terminalShell,
4006
+ name: shellDisplayName(terminalShell)
4007
+ }),
4008
+ "settings.get": () => {
4009
+ const settings = getSettings();
4010
+ return settings === void 0 ? {
4011
+ value: void 0,
4012
+ revision: void 0,
4013
+ externalDisable: false
4014
+ } : {
4015
+ ...settings.get(),
4016
+ externalDisable: settings.externalDisable()
4017
+ };
4018
+ },
4019
+ "settings.update": async (payload) => {
4020
+ const settings = getSettings();
4021
+ if (settings === void 0) throw new SidebarError("settings-rejected", "the settings service is not mounted in this deployment", 503);
4022
+ const record = payload;
4023
+ const patch = record?.patch;
4024
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) throw new SidebarError("bad-request", "patch must be a plain object");
4025
+ const expectedRevision = typeof record?.expectedRevision === "number" ? record.expectedRevision : void 0;
4026
+ try {
4027
+ return await settings.update(patch, expectedRevision);
4028
+ } catch (error) {
4029
+ if (error instanceof SettingsConflictError) throw new SidebarError("settings-conflict", error.message, 409);
4030
+ throw new SidebarError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
4031
+ }
4032
+ },
4033
+ "browser.probe": async (payload) => {
4034
+ const raw = requireString(payload, "url");
4035
+ let parsed;
4036
+ try {
4037
+ parsed = new URL(raw);
4038
+ } catch {
4039
+ throw new SidebarError("bad-request", "invalid url", 400);
4040
+ }
4041
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new SidebarError("bad-request", "only http/https urls can be probed", 400);
4042
+ if (isLoopbackHostname(parsed.hostname)) {
4043
+ const prefs = getSettings()?.get()?.value;
4044
+ const allowlist = typeof prefs?.browserAllowedLoopback === "string" ? prefs.browserAllowedLoopback : "";
4045
+ if (!(allowlist.trim() !== "" && parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port))) throw new SidebarError("bad-request", "local addresses are not probed", 400);
4046
+ }
4047
+ const controller = new AbortController();
4048
+ const timer = setTimeout(() => controller.abort(), 8e3);
4049
+ try {
4050
+ let response = await fetch(parsed, {
4051
+ method: "HEAD",
4052
+ redirect: "follow",
4053
+ signal: controller.signal
4054
+ });
4055
+ let retriedFromHeadRejection = false;
4056
+ if (response.status === 405 || response.status === 501) {
4057
+ response = await fetch(parsed, {
4058
+ method: "GET",
4059
+ redirect: "follow",
4060
+ signal: controller.signal
4061
+ });
4062
+ retriedFromHeadRejection = true;
4063
+ }
4064
+ if (!(response.headers.get("content-security-policy") !== null || response.headers.get("x-frame-options") !== null) && !retriedFromHeadRejection && response.status !== 405 && response.status !== 501) response = await fetch(parsed, {
4065
+ method: "GET",
4066
+ redirect: "follow",
4067
+ signal: controller.signal
4068
+ });
4069
+ const frameAncestors = extractFrameAncestors(response.headers.get("content-security-policy"));
4070
+ const xFrameOptions = response.headers.get("x-frame-options");
4071
+ response.body?.cancel();
4072
+ return {
4073
+ reachable: true,
4074
+ url: response.url,
4075
+ status: response.status,
4076
+ ...xFrameOptions !== null ? { xFrameOptions } : {},
4077
+ ...frameAncestors !== void 0 ? { frameAncestors } : {}
4078
+ };
4079
+ } catch {
4080
+ return { reachable: false };
4081
+ } finally {
4082
+ clearTimeout(timer);
4083
+ }
4084
+ },
4085
+ "open.external": (payload) => {
4086
+ const action = payload?.action;
4087
+ if (action === "reveal") return launchExternal("reveal", requireString(payload, "path"));
4088
+ if (action === "url") return launchExternal("url", requireString(payload, "url"));
4089
+ throw new SidebarError("bad-request", "action must be \"reveal\" or \"url\"");
4090
+ },
4091
+ ...buildSidechatApi(ctx)
4092
+ };
4093
+ }
4094
+ /**
4095
+ * Plugin body: mount the fenced routes and the pty lifecycle.
4096
+ * @param ctx - host plugin context (webServer, sessions, webRuntime).
4097
+ * @param config - deployment-provided limits; the Loader validates against
4098
+ * {@link Config} and fills defaults, direct callers get them from
4099
+ * {@link resolveSidebarConfig}.
4100
+ */
4101
+ function apply(ctx, config) {
4102
+ ensureSpawnHelper();
4103
+ const resolved = resolveSidebarConfig(config);
4104
+ const terminalShell = defaultShell({ explicit: resolved.shell });
4105
+ const fence = (req) => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts);
4106
+ const nodePty = loadNodePty();
4107
+ if (nodePty === null) {
4108
+ const status = depsStatus();
4109
+ const detail = status.ok ? "unknown cause" : `${status.cause}. Repair: ${status.command}`;
4110
+ ctx.logger?.warn(`[dsh-coding-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`);
4111
+ }
4112
+ const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty) : null;
4113
+ const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty) : null;
4114
+ const agentOpenRegistry = new AgentOpenRegistry();
4115
+ let settingsFace;
4116
+ let toolsDisposers = null;
4117
+ let openToolsDisposers = null;
4118
+ const syncToolsGate = (scope) => {
4119
+ if (scope.get().agentTerminalTools) {
4120
+ if (toolsDisposers === null) {
4121
+ if (agentPtyRegistry === null) return;
4122
+ toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace));
4123
+ }
4124
+ } else if (toolsDisposers !== null) {
4125
+ toolsDisposers();
4126
+ toolsDisposers = null;
4127
+ agentPtyRegistry?.disposeAll();
4128
+ }
4129
+ };
4130
+ ctx.inject(["settings"], (sctx) => {
4131
+ const ns = SIDEBAR_PREFS_NS;
4132
+ const scope = sctx.settings.register(ns, PrefsSchema);
4133
+ const viewOf = () => {
4134
+ const descriptor = sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
4135
+ return descriptor === void 0 ? {
4136
+ value: void 0,
4137
+ revision: void 0
4138
+ } : {
4139
+ value: descriptor.value,
4140
+ revision: descriptor.revision
4141
+ };
4142
+ };
4143
+ const externalDisable = () => {
4144
+ return (sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === "aionui-panel")?.value)?.rightPanel === "aionui-panel";
4145
+ };
4146
+ settingsFace = {
4147
+ get: viewOf,
4148
+ externalDisable,
4149
+ update: async (patch, expectedRevision) => {
4150
+ await sctx.settings.update(ns, patch, expectedRevision);
4151
+ return viewOf();
4152
+ }
4153
+ };
4154
+ syncToolsGate(scope);
4155
+ const syncOpenToolsGate = () => {
4156
+ if (scope.get().agentOpenTools) {
4157
+ if (openToolsDisposers === null) openToolsDisposers = registerOpenTool(ctx, agentOpenRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => {
4158
+ const value = (settingsFace?.get())?.value;
4159
+ return value !== null && typeof value === "object" ? value : SIDEBAR_PREFS_DEFAULTS;
4160
+ });
4161
+ } else if (openToolsDisposers !== null) {
4162
+ openToolsDisposers();
4163
+ openToolsDisposers = null;
4164
+ agentOpenRegistry.drainAll();
4165
+ }
4166
+ };
4167
+ syncOpenToolsGate();
4168
+ scope.watch(() => {
4169
+ syncToolsGate(scope);
4170
+ syncOpenToolsGate();
4171
+ });
4172
+ });
4173
+ const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace);
4174
+ ctx.effect(() => ctx.webServer.register({
4175
+ kind: "prefix",
4176
+ path: "/sidebar/api",
4177
+ handler: async (req, res) => {
4178
+ if (!fence(req)) {
4179
+ writeJson(res, 403, {
4180
+ ok: false,
4181
+ error: {
4182
+ code: "forbidden",
4183
+ message: "forbidden"
4184
+ }
4185
+ });
4186
+ return;
4187
+ }
4188
+ if (req.method !== "POST") {
4189
+ writeJson(res, 405, {
4190
+ ok: false,
4191
+ error: {
4192
+ code: "method-error",
4193
+ message: "method not allowed"
4194
+ }
4195
+ });
4196
+ return;
4197
+ }
4198
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
4199
+ const method = pathname.startsWith("/sidebar/api/") ? pathname.slice(13) : void 0;
4200
+ if (method === void 0 || method.includes("/")) {
4201
+ writeError(res, new SidebarError("not-found", "unknown sidebar API method", 404));
4202
+ return;
4203
+ }
4204
+ try {
4205
+ const payload = await readJsonBody(req);
4206
+ const handler = api[method];
4207
+ if (handler === void 0) throw new SidebarError("not-found", `unknown sidebar API method "${method}"`, 404);
4208
+ writeOk(res, await handler(payload));
4209
+ } catch (error) {
4210
+ writeError(res, error);
4211
+ }
4212
+ }
4213
+ }), "dsh-coding-sidebar: /sidebar/api routes");
4214
+ ctx.effect(() => ctx.webServer.register({
4215
+ kind: "exact",
4216
+ path: "/sidebar/upload",
4217
+ handler: async (req, res) => {
4218
+ if (!fence(req)) {
4219
+ writeJson(res, 403, {
4220
+ ok: false,
4221
+ error: {
4222
+ code: "forbidden",
4223
+ message: "forbidden"
4224
+ }
4225
+ });
4226
+ return;
4227
+ }
4228
+ if (req.method !== "POST") {
4229
+ writeJson(res, 405, {
4230
+ ok: false,
4231
+ error: {
4232
+ code: "method-error",
4233
+ message: "method not allowed"
4234
+ }
4235
+ });
4236
+ return;
4237
+ }
4238
+ try {
4239
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
4240
+ const sessionId = url.searchParams.get("sessionId");
4241
+ const dir = url.searchParams.get("dir");
4242
+ const relativePath = url.searchParams.get("relativePath");
4243
+ if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === "") throw new SidebarError("bad-request", "sessionId, dir, and relativePath are required");
4244
+ const { path, size } = await writeWorkspaceUpload({
4245
+ cwd: await sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0),
4246
+ dir,
4247
+ relativePath,
4248
+ chunks: req,
4249
+ limit: resolved.uploadLimit
4250
+ });
4251
+ writeOk(res, {
4252
+ path,
4253
+ size
4254
+ });
4255
+ } catch (error) {
4256
+ writeError(res, error);
4257
+ }
4258
+ }
4259
+ }), "dsh-coding-sidebar: /sidebar/upload route");
4260
+ ctx.effect(() => registerBundleRoute(ctx, fence), "dsh-coding-sidebar: /sidebar/bundle chunk route");
4261
+ ctx.effect(() => ctx.webServer.register({
4262
+ kind: "prefix",
4263
+ path: "/sidebar/file",
4264
+ handler: async (req, res) => {
4265
+ if (!fence(req)) {
4266
+ res.writeHead(403);
4267
+ res.end("forbidden");
4268
+ return;
4269
+ }
4270
+ if (req.method !== "GET") {
4271
+ res.writeHead(405);
4272
+ res.end();
4273
+ return;
4274
+ }
4275
+ try {
4276
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
4277
+ const sessionId = url.searchParams.get("sessionId");
4278
+ const raw = url.searchParams.get("path");
4279
+ if (sessionId === null || raw === null) throw new SidebarError("bad-request", "sessionId and path are required");
4280
+ const path = await ensureWorkspacePath(await sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0), raw);
4281
+ const info = await stat(path);
4282
+ if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
4283
+ const type = mediaTypeForPath(path);
4284
+ const body = await readFile(path);
4285
+ const headers = {
4286
+ "content-type": type,
4287
+ "cache-control": "no-cache"
4288
+ };
4289
+ if (url.searchParams.get("download") === "1") headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`;
4290
+ res.writeHead(200, headers);
4291
+ res.end(body);
4292
+ } catch (error) {
4293
+ writeError(res, error);
4294
+ }
4295
+ }
4296
+ }), "dsh-coding-sidebar: /sidebar/file media route");
4297
+ ctx.effect(() => ctx.webServer.register({
4298
+ kind: "prefix",
4299
+ path: "/sidebar/html",
4300
+ handler: async (req, res) => {
4301
+ if (!fence(req)) {
4302
+ res.writeHead(403);
4303
+ res.end("forbidden");
4304
+ return;
4305
+ }
4306
+ if (req.method !== "GET") {
4307
+ res.writeHead(405);
4308
+ res.end();
4309
+ return;
4310
+ }
4311
+ try {
4312
+ const decoded = decodeHtmlUrl(new URL(req.url ?? "/", "http://dsh.internal").pathname);
4313
+ if (!decoded.ok) {
4314
+ writeError(res, new SidebarError("bad-request", decoded.message, decoded.status));
4315
+ return;
4316
+ }
4317
+ const { sessionId, path } = decoded.ref;
4318
+ const absolute = await ensureWorkspacePath(await sessionCwdOf(ctx, sessionId), path);
4319
+ const info = await stat(absolute);
4320
+ if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
4321
+ const type = mediaTypeForPath(absolute);
4322
+ const body = await readFile(absolute);
4323
+ res.writeHead(200, {
4324
+ "content-type": type === "text/html" ? "text/html; charset=utf-8" : type,
4325
+ "cache-control": "no-cache",
4326
+ "x-content-type-options": "nosniff",
4327
+ "referrer-policy": "no-referrer",
4328
+ "content-security-policy": "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'"
4329
+ });
4330
+ res.end(body);
4331
+ } catch (error) {
4332
+ writeError(res, error);
4333
+ }
4334
+ }
4335
+ }), "dsh-coding-sidebar: /sidebar/html preview route");
4336
+ const wss = new WebSocketServer({ noServer: true });
4337
+ ctx.effect(() => ctx.webServer.registerUpgrade({
4338
+ path: "/sidebar/ws/terminal",
4339
+ handler: (req, socket, head) => {
4340
+ if (!fence(req)) {
4341
+ socket.destroy();
4342
+ return;
4343
+ }
4344
+ wss.handleUpgrade(req, socket, head, (ws) => {
4345
+ attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace);
4346
+ });
4347
+ }
4348
+ }), "dsh-coding-sidebar: terminal WebSocket");
4349
+ const agentListWss = new WebSocketServer({ noServer: true });
4350
+ ctx.effect(() => ctx.webServer.registerUpgrade({
4351
+ path: "/sidebar/ws/agent-terminals",
4352
+ handler: (req, socket, head) => {
4353
+ if (!fence(req)) {
4354
+ socket.destroy();
4355
+ return;
4356
+ }
4357
+ agentListWss.handleUpgrade(req, socket, head, (ws) => {
4358
+ attachAgentList(agentPtyRegistry, ws, req);
4359
+ });
4360
+ }
4361
+ }), "dsh-coding-sidebar: agent-terminals push WebSocket");
4362
+ const agentOpenWss = new WebSocketServer({ noServer: true });
4363
+ ctx.effect(() => ctx.webServer.registerUpgrade({
4364
+ path: "/sidebar/ws/agent-opens",
4365
+ handler: (req, socket, head) => {
4366
+ if (!fence(req)) {
4367
+ socket.destroy();
4368
+ return;
4369
+ }
4370
+ agentOpenWss.handleUpgrade(req, socket, head, (ws) => {
4371
+ attachAgentOpen(agentOpenRegistry, ws, req);
4372
+ });
4373
+ }
4374
+ }), "dsh-coding-sidebar: agent-opens push WebSocket");
4375
+ ctx.effect(() => () => {
4376
+ toolsDisposers?.();
4377
+ openToolsDisposers?.();
4378
+ ptyManager?.disposeAll();
4379
+ agentPtyRegistry?.disposeAll();
4380
+ agentOpenRegistry.dispose();
4381
+ wss.close();
4382
+ agentListWss.close();
4383
+ agentOpenWss.close();
4384
+ }, "dsh-coding-sidebar: teardown");
4385
+ }
4386
+ /** Push queued `sidebar_open` requests for one session to a connected view. */
4387
+ async function attachAgentOpen(registry, ws, req) {
4388
+ try {
4389
+ const sessionId = new URL(req.url ?? "/", "http://dsh.internal").searchParams.get("sessionId");
4390
+ if (sessionId === null) {
4391
+ ws.close(1008, "sessionId is required");
4392
+ return;
4393
+ }
4394
+ const send = (request) => {
4395
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(request));
4396
+ };
4397
+ const unsubscribe = registry.attach(sessionId, send);
4398
+ ws.on("close", () => {
4399
+ unsubscribe();
4400
+ });
4401
+ ws.on("error", () => {
4402
+ unsubscribe();
4403
+ });
4404
+ } catch (error) {
4405
+ ws.close(1011, error instanceof Error ? error.message : String(error));
4406
+ }
4407
+ }
4408
+ /** Push the live agent-terminal list for one session to a connected sidebar view. */
4409
+ async function attachAgentList(registry, ws, req) {
4410
+ try {
4411
+ const sessionId = new URL(req.url ?? "/", "http://dsh.internal").searchParams.get("sessionId");
4412
+ if (sessionId === null) {
4413
+ ws.close(1008, "sessionId is required");
4414
+ return;
4415
+ }
4416
+ const send = () => {
4417
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(registry?.list(sessionId) ?? []));
4418
+ };
4419
+ send();
4420
+ const unsubscribe = registry?.subscribe(send);
4421
+ ws.on("close", () => {
4422
+ unsubscribe?.();
4423
+ });
4424
+ ws.on("error", () => {
4425
+ unsubscribe?.();
4426
+ });
4427
+ } catch (error) {
4428
+ ws.close(1011, error instanceof Error ? error.message : String(error));
4429
+ }
4430
+ }
4431
+ /**
4432
+ * Wire one terminal socket to its pty: replay transcript, pump both ways.
4433
+ * Two attach modes share the wire protocol:
4434
+ * - `?uuid=...` attaches to an agent-owned terminal (created by the
4435
+ * `terminal_create` tool). The close frame kills the pty immediately
4436
+ * (the agent's terminal closes when the user closes the sidebar tab); a
4437
+ * bare socket drop (refresh, tab switch) leaves the pty alive for the
4438
+ * reconnect grace, exactly like UI-tab terminals.
4439
+ * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
4440
+ * created it from the + menu). The close frame schedules a 0-ms close
4441
+ * (the host's reconnect grace keeps the shell alive across a refresh).
4442
+ * The park frame (sent when the user switches to another conversation)
4443
+ * marks the pty as parked so the upcoming bare socket drop does NOT start
4444
+ * the grace countdown — the tab is still open in its session's state, so
4445
+ * the shell must survive until the user switches back or closes the tab.
4446
+ */
4447
+ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, getSettings) {
4448
+ try {
4449
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
4450
+ const uuid = url.searchParams.get("uuid");
4451
+ if (uuid !== null) {
4452
+ if (agentPtyRegistry === null) {
4453
+ ws.close(1011, `agent terminal "${uuid}" not found`);
4454
+ return;
4455
+ }
4456
+ const handle = agentPtyRegistry.get(uuid);
4457
+ if (handle === void 0) {
4458
+ ws.close(1011, `agent terminal "${uuid}" not found`);
4459
+ return;
4460
+ }
4461
+ pumpAgentTerminal(agentPtyRegistry, handle, ws);
4462
+ return;
4463
+ }
4464
+ const sessionId = url.searchParams.get("sessionId");
4465
+ const tabId = url.searchParams.get("tab");
4466
+ if (sessionId === null || tabId === null) {
4467
+ ws.close(1008, "either ?uuid or ?sessionId+?tab are required");
4468
+ return;
4469
+ }
4470
+ if (ptyManager === null) {
4471
+ ws.close(1011, PTY_DEPS_MISSING);
4472
+ return;
4473
+ }
4474
+ const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
4475
+ const overrides = shellOverridesOf(getSettings);
4476
+ const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs);
4477
+ if (handle.transcript !== "") ws.send(handle.transcript);
4478
+ const onData = (data) => {
4479
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
4480
+ };
4481
+ const onExit = ({ exitCode }) => {
4482
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
4483
+ };
4484
+ const dataSub = handle.pty.onData(onData);
4485
+ const exitSub = handle.pty.onExit(onExit);
4486
+ ws.on("message", (data) => {
4487
+ const text = data.toString("utf8");
4488
+ let control = null;
4489
+ try {
4490
+ const parsed = JSON.parse(text);
4491
+ if (parsed !== null && typeof parsed === "object") control = parsed;
4492
+ } catch {}
4493
+ if (control !== null && control.type === "close") {
4494
+ ptyManager.scheduleClose(handle.key, 0);
4495
+ return;
4496
+ }
4497
+ if (control !== null && control.type === "park") {
4498
+ ptyManager.park(handle.key);
4499
+ return;
4500
+ }
4501
+ if (handle.exited) return;
4502
+ if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
4503
+ const dims = clampDims(control.cols, control.rows);
4504
+ handle.pty.resize(dims.cols, dims.rows);
4505
+ } else handle.pty.write(text);
4506
+ });
4507
+ ws.on("close", () => {
4508
+ dataSub.dispose();
4509
+ exitSub.dispose();
4510
+ if (!ptyManager.isParked(handle.key)) ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs);
4511
+ });
4512
+ } catch (error) {
4513
+ ws.close(1011, error instanceof Error ? error.message : String(error));
4514
+ }
4515
+ }
4516
+ /**
4517
+ * Pump one agent terminal's pty to a connected view. The close frame kills
4518
+ * the pty immediately (the agent's terminal closes when the user closes the
4519
+ * sidebar tab); a bare socket drop leaves the pty alive — the agent owns
4520
+ * the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
4521
+ * plugin teardown kills it.
4522
+ */
4523
+ function pumpAgentTerminal(registry, handle, ws) {
4524
+ if (handle.transcript !== "") ws.send(handle.transcript);
4525
+ const onData = (data) => {
4526
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
4527
+ };
4528
+ const onExit = ({ exitCode }) => {
4529
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
4530
+ };
4531
+ const dataSub = handle.pty.onData(onData);
4532
+ const exitSub = handle.pty.onExit(onExit);
4533
+ ws.on("message", (data) => {
4534
+ if (handle.exited) return;
4535
+ const text = data.toString("utf8");
4536
+ let control = null;
4537
+ try {
4538
+ const parsed = JSON.parse(text);
4539
+ if (parsed !== null && typeof parsed === "object") control = parsed;
4540
+ } catch {}
4541
+ if (control !== null && control.type === "close") {
4542
+ registry.close(handle.uuid);
4543
+ return;
4544
+ }
4545
+ if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
4546
+ const dims = clampDims(control.cols, control.rows);
4547
+ handle.pty.resize(dims.cols, dims.rows);
4548
+ } else if (control === null) handle.pty.write(text);
4549
+ });
4550
+ ws.on("close", () => {
4551
+ dataSub.dispose();
4552
+ exitSub.dispose();
4553
+ });
4554
+ }
4555
+ //#endregion
4556
+ export { Config, apply, inject, mediaTypeForPath, name };