@keith9681/dsh-sidebar 0.15.2

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