dsh-better-sidebar 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -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 +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
package/lib/index.js ADDED
@@ -0,0 +1,2604 @@
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, resolve } from "node:path";
4
+ import { WebSocket, WebSocketServer } from "ws";
5
+ import z from "schemastery";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawn } from "node:child_process";
9
+ import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
10
+ import { chmodSync, existsSync } from "node:fs";
11
+ import * as nodePty from "node-pty";
12
+ import { defineTool } from "@deepseek-ai/dsh-tools";
13
+ //#region src/prefs-shared.ts
14
+ /**
15
+ * Shared "Side card" preference vocabulary (types + constants), consumed by
16
+ * BOTH halves: the host registers the schemastery schema over these values
17
+ * (config.ts) and the client reads/writes them through the settings RPC
18
+ * (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
19
+ * the browser bundle never pulls the schema runtime in.
20
+ */
21
+ /** The user-settings namespace holding the side card preferences. */
22
+ const SIDEBAR_PREFS_NS = "dsh-better-sidebar";
23
+ //#endregion
24
+ //#region src/config.ts
25
+ /**
26
+ * Serializable configuration and defaults for the sidebar host half. Loader
27
+ * schema validation normally fills defaults; {@link resolveSidebarConfig}
28
+ * applies the same defaults for direct callers that bypass the Loader.
29
+ * @module dsh-better-sidebar/config
30
+ */
31
+ /** Schemastery schema for the plugin configuration. */
32
+ const Config = z.object({
33
+ readLimit: z.number().step(1).min(1).default(524288),
34
+ mediaLimit: z.number().step(1).min(1).default(20971520),
35
+ listLimit: z.number().step(1).min(1).default(1e3),
36
+ terminalsPerSession: z.number().step(1).min(1).default(3),
37
+ reconnectGraceMs: z.number().step(1).min(0).default(3e4)
38
+ });
39
+ /**
40
+ * Apply direct-call defaults after Loader schema validation has normally run.
41
+ *
42
+ * @param config - Deployment-provided sidebar host settings.
43
+ * @returns Complete settings consumed by the host half.
44
+ */
45
+ function resolveSidebarConfig(config) {
46
+ return {
47
+ readLimit: config?.readLimit ?? 524288,
48
+ mediaLimit: config?.mediaLimit ?? 20971520,
49
+ listLimit: config?.listLimit ?? 1e3,
50
+ terminalsPerSession: config?.terminalsPerSession ?? 3,
51
+ reconnectGraceMs: config?.reconnectGraceMs ?? 3e4
52
+ };
53
+ }
54
+ /** Schemastery schema for the user-facing preferences (validated by the settings service). */
55
+ const PrefsSchema = z.object({
56
+ openByDefault: z.boolean().default(true),
57
+ defaultWidthPercent: z.number().step(1).min(20).max(60).default(30),
58
+ autoOpenSubagent: z.boolean().default(true),
59
+ autoOpenJobs: z.boolean().default(true),
60
+ agentTerminalTools: z.boolean().default(false),
61
+ bottomPanelAutoTerminal: z.boolean().default(true),
62
+ interceptOpenPath: z.boolean().default(true),
63
+ htmlViewerNoSandbox: z.boolean().default(false),
64
+ htmlViewerDefaultUnsafe: z.boolean().default(false),
65
+ browserNoSandbox: z.boolean().default(false),
66
+ browserInterceptLinks: z.boolean().default(true),
67
+ tabsEnabled: z.dict(z.boolean()).default({}),
68
+ viewersEnabled: z.dict(z.boolean()).default({})
69
+ });
70
+ //#endregion
71
+ //#region src/wire.ts
72
+ /** One API failure with its wire code and HTTP status. */
73
+ var SidebarError = class extends Error {
74
+ code;
75
+ status;
76
+ constructor(code, message, status = 400) {
77
+ super(message);
78
+ this.code = code;
79
+ this.status = status;
80
+ }
81
+ };
82
+ /** Body size bound of one JSON request (defense against unbounded reads). */
83
+ const MAX_BODY_BYTES = 1 << 20;
84
+ /** Read and parse the JSON request body (bounded; malformed → bad-request). */
85
+ async function readJsonBody(req) {
86
+ const chunks = [];
87
+ let total = 0;
88
+ for await (const chunk of req) {
89
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
90
+ total += buffer.length;
91
+ if (total > MAX_BODY_BYTES) throw new SidebarError("bad-request", "request body too large");
92
+ chunks.push(buffer);
93
+ }
94
+ const text = Buffer.concat(chunks).toString("utf8");
95
+ if (text.trim() === "") return {};
96
+ try {
97
+ return JSON.parse(text);
98
+ } catch {
99
+ throw new SidebarError("bad-request", "request body is not valid JSON");
100
+ }
101
+ }
102
+ /** Write a JSON response with the given status. */
103
+ function writeJson(res, status, body) {
104
+ const payload = JSON.stringify(body);
105
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
106
+ res.end(payload);
107
+ }
108
+ /** Write the success envelope. */
109
+ function writeOk(res, value) {
110
+ writeJson(res, 200, {
111
+ ok: true,
112
+ value
113
+ });
114
+ }
115
+ /** Write the failure envelope for any thrown value (unknown → internal 500). */
116
+ function writeError(res, error) {
117
+ if (error instanceof SidebarError) {
118
+ writeJson(res, error.status, {
119
+ ok: false,
120
+ error: {
121
+ code: error.code,
122
+ message: error.message
123
+ }
124
+ });
125
+ return;
126
+ }
127
+ writeJson(res, 500, {
128
+ ok: false,
129
+ error: {
130
+ code: "internal",
131
+ message: error instanceof Error ? error.message : String(error)
132
+ }
133
+ });
134
+ }
135
+ /** Narrow an unknown payload value to a string, else throw bad-request. */
136
+ function requireString(payload, key) {
137
+ const value = payload?.[key];
138
+ if (typeof value !== "string" || value === "") throw new SidebarError("bad-request", `missing or invalid "${key}"`);
139
+ return value;
140
+ }
141
+ //#endregion
142
+ //#region src/fs-tree.ts
143
+ /**
144
+ * Single-level directory listing for the sidebar explorer. Streams the level
145
+ * with opendir, sorts directories first then names (case-insensitive), and
146
+ * marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
147
+ * reported as files without probing their target — the explorer shows what
148
+ * dirent says, keeping the read cheap for arbitrarily large levels.
149
+ */
150
+ /** Directory-first, case-insensitive name ordering (VSCode explorer order). */
151
+ function compareEntries(a, b) {
152
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
153
+ return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
154
+ }
155
+ /**
156
+ * List one directory level.
157
+ * @param path - absolute directory path.
158
+ * @param maxEntries - row bound of one level (extra rows flag `truncated`).
159
+ * @returns the sorted listing.
160
+ * @throws {SidebarError} fs-error when the level is unreadable or not a directory.
161
+ */
162
+ async function listDirectory(path, maxEntries = 1e3) {
163
+ let level;
164
+ try {
165
+ level = await opendir(path);
166
+ } catch (error) {
167
+ throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
168
+ }
169
+ const rows = [];
170
+ let overflow = 0;
171
+ try {
172
+ for await (const dirent of level) {
173
+ if (rows.length >= maxEntries) {
174
+ overflow += 1;
175
+ continue;
176
+ }
177
+ rows.push({
178
+ name: dirent.name,
179
+ path: join(path, dirent.name),
180
+ isDir: dirent.isDirectory(),
181
+ hidden: dirent.name.startsWith(".")
182
+ });
183
+ }
184
+ } catch (error) {
185
+ throw new SidebarError("fs-error", `cannot list "${path}": ${messageOf(error)}`, 400);
186
+ }
187
+ rows.sort(compareEntries);
188
+ return {
189
+ path,
190
+ entries: rows,
191
+ truncated: overflow > 0
192
+ };
193
+ }
194
+ /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
195
+ function rootLabel(path) {
196
+ const base = basename(path);
197
+ return base !== "" ? base : path;
198
+ }
199
+ /** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
200
+ function parentOf(path) {
201
+ const parent = dirname(path);
202
+ return parent === path ? void 0 : parent;
203
+ }
204
+ /** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
205
+ function requireAbsolute(path) {
206
+ if (!path.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(path)) throw new SidebarError("fs-error", `"${path}" is not an absolute path`, 400);
207
+ return resolve(path);
208
+ }
209
+ /**
210
+ * Whether `target` lies under `base` (or equals it), tolerant of separator
211
+ * style and — on Windows, where the filesystem is case-insensitive — of
212
+ * letter case. The media route uses this instead of a raw `startsWith` so a
213
+ * case-mismatched or mixed-separator path can never be misclassified
214
+ * (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
215
+ * @param platform - filesystem semantics; injectable so both branches are
216
+ * unit-testable on any host.
217
+ */
218
+ function isWithin(base, target, platform = process.platform) {
219
+ const norm = (value) => value.replace(/[\\/]+/g, "/").replace(/\/$/, "");
220
+ const b = norm(base);
221
+ const t = norm(target);
222
+ if (platform === "win32") {
223
+ const lb = b.toLowerCase();
224
+ const lt = t.toLowerCase();
225
+ return lt === lb || lt.startsWith(`${lb}/`);
226
+ }
227
+ return t === b || t.startsWith(`${b}/`);
228
+ }
229
+ /** Message text of an unknown thrown value. */
230
+ function messageOf(error) {
231
+ return error instanceof Error ? error.message : String(error);
232
+ }
233
+ /**
234
+ * Decode a route pathname into the session + absolute file path. Rejects
235
+ * a wrong prefix (404), an empty or double-slash path, malformed percent
236
+ * encoding, and a missing sessionId or file path (400). The caller still
237
+ * must bound the decoded path with requireAbsolute + isWithin(cwd) — a
238
+ * decoded `..` segment resolves outside the cwd and is refused there.
239
+ */
240
+ function decodeHtmlUrl(pathname) {
241
+ if (!pathname.startsWith("/sidebar/html/")) return {
242
+ ok: false,
243
+ status: 404,
244
+ message: "not an html route"
245
+ };
246
+ const rest = pathname.slice(14);
247
+ if (rest === "" || rest.includes("//")) return {
248
+ ok: false,
249
+ status: 400,
250
+ message: "invalid html route path"
251
+ };
252
+ let segments;
253
+ try {
254
+ segments = rest.split("/").map((segment) => decodeURIComponent(segment));
255
+ } catch {
256
+ return {
257
+ ok: false,
258
+ status: 400,
259
+ message: "malformed URL encoding"
260
+ };
261
+ }
262
+ const [sessionId, ...pathSegments] = segments;
263
+ if (sessionId === void 0 || sessionId === "" || pathSegments.length === 0 || pathSegments.some((segment) => segment === "")) return {
264
+ ok: false,
265
+ status: 400,
266
+ message: "sessionId and file path are required"
267
+ };
268
+ return {
269
+ ok: true,
270
+ ref: {
271
+ sessionId,
272
+ path: `/${pathSegments.join("/")}`
273
+ }
274
+ };
275
+ }
276
+ //#endregion
277
+ //#region src/browser-probe.ts
278
+ /**
279
+ * Pure helpers for the `browser.probe` route (sidebar browser): the host
280
+ * fetches the response HEADERS of a URL the user is browsing and the client
281
+ * decides whether the target site forbids being embedded (X-Frame-Options /
282
+ * CSP frame-ancestors are exactly the signals the browser enforces when it
283
+ * refuses an iframe load). Kept dependency-free so the parser is
284
+ * unit-testable.
285
+ */
286
+ /**
287
+ * Extract the `frame-ancestors` source list of a Content-Security-Policy
288
+ * header, or undefined when the directive is absent (or empty). The
289
+ * directive is the only one with a source list; sources are space-separated
290
+ * tokens (`'none'`, `'self'`, `*`, or origins).
291
+ */
292
+ function extractFrameAncestors(csp) {
293
+ if (csp === null) return void 0;
294
+ for (const directive of csp.split(";")) {
295
+ const parts = directive.trim().split(/\s+/);
296
+ if (parts[0] === "frame-ancestors") {
297
+ const sources = parts.slice(1).filter((source) => source !== "");
298
+ return sources.length === 0 ? void 0 : sources;
299
+ }
300
+ }
301
+ }
302
+ //#endregion
303
+ //#region src/trust-fence.ts
304
+ function header(headers, name) {
305
+ const value = headers[name];
306
+ return typeof value === "string" ? value : void 0;
307
+ }
308
+ /** Normalized URL of a Host-header authority, or undefined when unparsable. */
309
+ function parseAuthority(authority) {
310
+ try {
311
+ return new URL(`http://${authority}`);
312
+ } catch {
313
+ return;
314
+ }
315
+ }
316
+ /** Whether a normalized URL hostname names the local loopback authority. */
317
+ function isLoopbackHostname(hostname) {
318
+ if (hostname === "localhost" || hostname === "[::1]") return true;
319
+ const parts = hostname.split(".");
320
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
321
+ }
322
+ /** Canonical authority form: hostname, or hostname:port when a port was written. */
323
+ function canonicalAuthority(entry, entryUrl) {
324
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
325
+ return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
326
+ }
327
+ /** Whether the request authority matches a trustedHosts entry (exact or port-less). */
328
+ function isTrustedAuthority(hostUrl, trustedHosts) {
329
+ return trustedHosts.some((entry) => {
330
+ const entryUrl = parseAuthority(entry);
331
+ if (entryUrl === void 0) return false;
332
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
333
+ });
334
+ }
335
+ /**
336
+ * Decide whether one sidebar request may reach the plugin routes.
337
+ * @param request - node HTTP request facts (headers).
338
+ * @param trustedHosts - non-loopback authorities this deployment serves.
339
+ * @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
340
+ */
341
+ function isTrustedApiRequest(request, trustedHosts) {
342
+ const host = header(request.headers, "host");
343
+ if (host === void 0) return false;
344
+ const hostUrl = parseAuthority(host);
345
+ if (hostUrl === void 0) return false;
346
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
347
+ if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
348
+ const origin = header(request.headers, "origin");
349
+ if (origin === void 0) return true;
350
+ try {
351
+ return new URL(origin).host === hostUrl.host;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+ //#endregion
357
+ //#region src/bundle-route.ts
358
+ /**
359
+ * Lazy chunk route: serves the client bundle's chunk scripts
360
+ * (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
361
+ * cannot serve arbitrary file names, so the plugin serves its own split
362
+ * bundles (lib/client-<name>.js) here; the client injects the script on
363
+ * first use of the feature that needs it (see src/client/chunk-loader.ts).
364
+ *
365
+ * Caching contract: every response carries `cache-control: no-cache` plus an
366
+ * ETag (content hash, memoized per file by mtime/size) and honors
367
+ * If-None-Match — the browser revalidates each fetch, but a 304 avoids
368
+ * re-downloading multi-MB chunks that did not change (page refresh, HMR
369
+ * re-activation). Same browser-trust fence as every other /sidebar route;
370
+ * only allowlisted chunk names are servable (no path traversal).
371
+ */
372
+ /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
373
+ const CHUNK_NAMES = [
374
+ "docx",
375
+ "xlsx",
376
+ "pptx",
377
+ "terminal",
378
+ "editor"
379
+ ];
380
+ /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
381
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
382
+ /** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
383
+ function shortHash(input) {
384
+ return createHash("sha1").update(input).digest("hex").slice(0, 12);
385
+ }
386
+ /** ETag memo: recompute the content hash only when the file's stat changed. */
387
+ const etags = /* @__PURE__ */ new Map();
388
+ /**
389
+ * The chunk file's ETag (quoted hash), or undefined when the file is
390
+ * missing. Hash is recomputed only when mtime/size changed (hashing a
391
+ * multi-MB chunk per request is wasteful).
392
+ */
393
+ async function etagOf(name, chunkDir) {
394
+ const path = join(chunkDir, `client-${name}.js`);
395
+ const key = `${chunkDir}:${name}`;
396
+ try {
397
+ const info = await stat(path);
398
+ const memo = etags.get(key);
399
+ if (memo !== void 0 && memo.mtimeMs === info.mtimeMs && memo.size === info.size) return memo.etag;
400
+ const etag = `"${shortHash(await readFile(path))}"`;
401
+ etags.set(key, {
402
+ mtimeMs: info.mtimeMs,
403
+ size: info.size,
404
+ etag
405
+ });
406
+ return etag;
407
+ } catch {
408
+ return;
409
+ }
410
+ }
411
+ /**
412
+ * Build the /sidebar/bundle route handler. `fence` is the shared browser-
413
+ * trust check every /sidebar route applies; `chunkDir` is the directory the
414
+ * chunk scripts live in (overridable for tests).
415
+ */
416
+ function createBundleRouteHandler(fence, chunkDir = LIB_DIR) {
417
+ return async (req, res) => {
418
+ if (!fence(req)) {
419
+ res.writeHead(403);
420
+ res.end("forbidden");
421
+ return;
422
+ }
423
+ if (req.method !== "GET" && req.method !== "HEAD") {
424
+ res.writeHead(405);
425
+ res.end();
426
+ return;
427
+ }
428
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
429
+ const name = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)?.[1];
430
+ if (name === void 0 || !CHUNK_NAMES.includes(name)) {
431
+ res.writeHead(404);
432
+ res.end("not found");
433
+ return;
434
+ }
435
+ const etag = await etagOf(name, chunkDir);
436
+ if (etag === void 0) {
437
+ res.writeHead(404);
438
+ res.end("not found");
439
+ return;
440
+ }
441
+ if (req.headers["if-none-match"] === etag) {
442
+ res.writeHead(304, {
443
+ "cache-control": "no-cache",
444
+ etag
445
+ });
446
+ res.end();
447
+ return;
448
+ }
449
+ try {
450
+ const body = await readFile(join(chunkDir, `client-${name}.js`));
451
+ res.writeHead(200, {
452
+ "content-type": "text/javascript; charset=utf-8",
453
+ "cache-control": "no-cache",
454
+ etag
455
+ });
456
+ res.end(body);
457
+ } catch {
458
+ res.writeHead(404);
459
+ res.end("not found");
460
+ }
461
+ };
462
+ }
463
+ /** Register the /sidebar/bundle route (disposed with the fiber). */
464
+ function registerBundleRoute(ctx, fence) {
465
+ return ctx.webServer.register({
466
+ kind: "prefix",
467
+ path: "/sidebar/bundle",
468
+ handler: createBundleRouteHandler(fence)
469
+ });
470
+ }
471
+ //#endregion
472
+ //#region src/git.ts
473
+ /**
474
+ * Git operations for the sidebar source-control panel. Everything goes
475
+ * through the system `git` binary spawned per request (no library, no state),
476
+ * with porcelain-parseable output formats (`-z` NUL framing, unit separators)
477
+ * so parsing never depends on locale or color config. All commands run with
478
+ * `-C <cwd>` on the session's working directory and `--no-pager` /
479
+ * `-c color.ui=false` so output stays machine-readable.
480
+ *
481
+ * Commits use the user's git global identity untouched (never sets
482
+ * user.name/user.email).
483
+ */
484
+ /** One git failure (stderr text as the message). */
485
+ var GitCommandError = class extends Error {
486
+ code;
487
+ command;
488
+ constructor(message, code = "git-error", command) {
489
+ super(message);
490
+ this.code = code;
491
+ this.command = command;
492
+ }
493
+ };
494
+ /** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
495
+ function parsePorcelainZ(output) {
496
+ const tokens = output.split("\0");
497
+ const entries = [];
498
+ let index = 0;
499
+ while (index < tokens.length) {
500
+ const token = tokens[index];
501
+ index += 1;
502
+ if (token === "") continue;
503
+ const xy = token.slice(0, 2);
504
+ const rest = token.slice(3);
505
+ entries.push({
506
+ path: rest,
507
+ xy
508
+ });
509
+ if ((xy[0] === "R" || xy[0] === "C") && tokens[index] !== void 0 && tokens[index] !== "") index += 1;
510
+ }
511
+ return entries;
512
+ }
513
+ /** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
514
+ function parseLogLines(output) {
515
+ const rows = [];
516
+ for (const line of output.split("\n")) {
517
+ if (line === "") continue;
518
+ const [hash, subject, author, date, hashFull, refs] = line.split("");
519
+ if (hash === void 0 || subject === void 0) continue;
520
+ rows.push({
521
+ hash,
522
+ subject,
523
+ author: author ?? "",
524
+ date: date ?? "",
525
+ hashFull: hashFull ?? hash,
526
+ refs: refs ?? ""
527
+ });
528
+ }
529
+ return rows;
530
+ }
531
+ /** Run one git command; resolves with stdout, rejects with GitCommandError. */
532
+ function runGit(cwd, args, timeoutMs = 3e4) {
533
+ const full = [
534
+ "-C",
535
+ cwd,
536
+ "--no-pager",
537
+ "-c",
538
+ "color.ui=false",
539
+ ...args
540
+ ];
541
+ return new Promise((resolvePromise, reject) => {
542
+ const child = spawn("git", full, {
543
+ stdio: [
544
+ "ignore",
545
+ "pipe",
546
+ "pipe"
547
+ ],
548
+ env: {
549
+ ...process.env,
550
+ GIT_OPTIONAL_LOCKS: "0"
551
+ }
552
+ });
553
+ let stdout = "";
554
+ let stderr = "";
555
+ const timer = setTimeout(() => {
556
+ child.kill("SIGKILL");
557
+ reject(new GitCommandError(`git ${args[0] ?? ""} timed out after ${timeoutMs}ms`, "git-error", args.join(" ")));
558
+ }, timeoutMs);
559
+ child.stdout.on("data", (chunk) => {
560
+ stdout += chunk.toString("utf8");
561
+ });
562
+ child.stderr.on("data", (chunk) => {
563
+ stderr += chunk.toString("utf8");
564
+ });
565
+ child.on("error", (error) => {
566
+ clearTimeout(timer);
567
+ reject(new GitCommandError(`cannot run git: ${error.message}`, "git-error", args.join(" ")));
568
+ });
569
+ child.on("close", (code) => {
570
+ clearTimeout(timer);
571
+ if (code === 0) resolvePromise(stdout);
572
+ else reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, "git-error", args.join(" ")));
573
+ });
574
+ });
575
+ }
576
+ /** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
577
+ async function isGitRepo(cwd) {
578
+ try {
579
+ return (await runGit(cwd, ["rev-parse", "--is-inside-work-tree"])).trim() === "true";
580
+ } catch {
581
+ return false;
582
+ }
583
+ }
584
+ /** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
585
+ async function repoRoot(cwd) {
586
+ return (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
587
+ }
588
+ /** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
589
+ async function currentBranch(cwd) {
590
+ return (await runGit(cwd, [
591
+ "rev-parse",
592
+ "--abbrev-ref",
593
+ "HEAD"
594
+ ])).trim();
595
+ }
596
+ /** Working-tree status (untracked included). */
597
+ async function status(cwd) {
598
+ if (!await isGitRepo(cwd)) return {
599
+ isRepo: false,
600
+ entries: []
601
+ };
602
+ const [branch, raw] = await Promise.all([currentBranch(cwd).catch(() => "HEAD"), runGit(cwd, [
603
+ "status",
604
+ "--porcelain=v1",
605
+ "-z",
606
+ "--untracked-files=normal"
607
+ ])]);
608
+ return {
609
+ isRepo: true,
610
+ branch,
611
+ entries: parsePorcelainZ(raw)
612
+ };
613
+ }
614
+ /** Diff text of the worktree (unstaged) or the index (staged). */
615
+ async function diff(cwd, path, staged) {
616
+ const args = [
617
+ "diff",
618
+ "--no-ext-diff",
619
+ "--no-color",
620
+ "-U3"
621
+ ];
622
+ if (staged) args.push("--cached");
623
+ if (path !== void 0) args.push("--", path);
624
+ return runGit(cwd, args);
625
+ }
626
+ /** Stage paths (all when path is undefined). */
627
+ async function stage(cwd, path) {
628
+ await runGit(cwd, [
629
+ "add",
630
+ "-A",
631
+ ...path !== void 0 ? ["--", path] : []
632
+ ]);
633
+ }
634
+ /** Unstage paths (all when path is undefined). */
635
+ async function unstage(cwd, path) {
636
+ await runGit(cwd, [
637
+ "reset",
638
+ "-q",
639
+ ...path !== void 0 ? ["--", path] : []
640
+ ]);
641
+ }
642
+ /** Commit the staged changes with a message (global identity untouched). */
643
+ async function commit(cwd, message) {
644
+ await runGit(cwd, [
645
+ "commit",
646
+ "-m",
647
+ message
648
+ ]);
649
+ }
650
+ /** Branch names (current first). */
651
+ async function branches(cwd) {
652
+ const [current, raw] = await Promise.all([currentBranch(cwd).catch(() => "HEAD"), runGit(cwd, [
653
+ "for-each-ref",
654
+ "--format=%(refname:short)",
655
+ "refs/heads"
656
+ ])]);
657
+ const names = raw.split("\n").filter((line) => line !== "");
658
+ return {
659
+ current,
660
+ names: names.includes(current) ? names : [current, ...names]
661
+ };
662
+ }
663
+ /** Switch to an existing branch. */
664
+ async function checkout(cwd, branch) {
665
+ await runGit(cwd, ["checkout", branch]);
666
+ }
667
+ /** Recent commit history (newest first), lazily pageable via skip/count. */
668
+ async function log(cwd, count = 30, skip = 0) {
669
+ return parseLogLines(await runGit(cwd, [
670
+ "log",
671
+ "-n",
672
+ String(count),
673
+ "--skip",
674
+ String(skip),
675
+ "--decorate=short",
676
+ "--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D"
677
+ ]));
678
+ }
679
+ /**
680
+ * Content of a file at a revision (`git show <rev>:<path>`), or null when the
681
+ * revision has no such path (a new/untracked file has no HEAD side).
682
+ */
683
+ async function show(cwd, rev, path) {
684
+ try {
685
+ return await runGit(cwd, ["show", `${rev}:${path}`]);
686
+ } catch {
687
+ return null;
688
+ }
689
+ }
690
+ /** Full patch text of one commit (`git show` with the commit header suppressed).
691
+ * Merge commits show their diff against the first parent (`-m --first-parent`
692
+ * is a no-op for regular commits), so a history click always has content. */
693
+ async function commitDiff(cwd, hash) {
694
+ return runGit(cwd, [
695
+ "show",
696
+ "--no-ext-diff",
697
+ "--no-color",
698
+ "--format=",
699
+ "-m",
700
+ "--first-parent",
701
+ hash
702
+ ]);
703
+ }
704
+ /** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
705
+ async function discard(cwd, path) {
706
+ await runGit(cwd, [
707
+ "checkout",
708
+ "--",
709
+ path
710
+ ]);
711
+ }
712
+ /** Revert one commit onto the current branch with an auto-generated message. */
713
+ async function revert(cwd, hash) {
714
+ await runGit(cwd, [
715
+ "revert",
716
+ "--no-edit",
717
+ hash
718
+ ]);
719
+ }
720
+ /** Cherry-pick one commit onto the current branch. */
721
+ async function cherryPick(cwd, hash) {
722
+ await runGit(cwd, ["cherry-pick", hash]);
723
+ }
724
+ //#endregion
725
+ //#region src/pty-manager.ts
726
+ /**
727
+ * PTY session table for the sidebar terminals. One node-pty process per
728
+ * `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
729
+ * (page refresh, tab switch) and reconnect to the same process by key.
730
+ * Output is mirrored into a bounded transcript ring (capped bytes) so a new
731
+ * connection replays history before live data. Sessions die only when the
732
+ * tab is closed or the plugin tears down.
733
+ */
734
+ /** Per-terminal transcript bound (bytes kept for replay). */
735
+ const TRANSCRIPT_LIMIT$1 = 1 << 20;
736
+ /**
737
+ * Restore the executable bit pnpm strips from node-pty's prebuilt
738
+ * spawn-helper (the macOS helper that forks and sets up the pty). Without it
739
+ * every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
740
+ * @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
741
+ * plugin activation so link-installed deployments get the fix too.
742
+ */
743
+ function ensureSpawnHelper() {
744
+ if (process.platform === "win32") return;
745
+ try {
746
+ const entry = createRequire(import.meta.url).resolve("node-pty");
747
+ const packageRoot = dirname(dirname(entry));
748
+ const candidates = [join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), join(packageRoot, "build", "Release", "spawn-helper")];
749
+ for (const helper of candidates) if (existsSync(helper)) chmodSync(helper, 493);
750
+ } catch {}
751
+ }
752
+ /**
753
+ * The terminal registry. `maxPerSession` bounds concurrent processes per
754
+ * conversation (the client caps tabs at the same number).
755
+ */
756
+ var PtyManager = class {
757
+ shell;
758
+ maxPerSession;
759
+ sessions = /* @__PURE__ */ new Map();
760
+ pendingCloses = /* @__PURE__ */ new Map();
761
+ constructor(shell, maxPerSession) {
762
+ this.shell = shell;
763
+ this.maxPerSession = maxPerSession;
764
+ }
765
+ /** All live terminal keys of one session. */
766
+ keysOf(sessionId) {
767
+ const keys = [];
768
+ for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) keys.push(handle.key);
769
+ return keys;
770
+ }
771
+ /**
772
+ * Open (or reuse) the terminal for a session/tab key. A handle whose
773
+ * process already exited is replaced with a fresh spawn (reconnecting a
774
+ * dead terminal must yield a live shell, not an input sink), and so is a
775
+ * live handle whose spawn cwd differs from the now-authoritative one (the
776
+ * first connect of a page load can arrive before the session hydrates, so
777
+ * it fell back to the process cwd — reconnecting with the real cwd must
778
+ * restart the shell in the right directory). Reopening also cancels any
779
+ * pending scheduled close (a reconnect within the grace window keeps the
780
+ * process alive).
781
+ * @param sessionId - conversation id.
782
+ * @param tabId - client tab id.
783
+ * @param cwd - initial working directory (the session's cwd).
784
+ * @param cols - initial terminal width.
785
+ * @param rows - initial terminal height.
786
+ * @returns the live handle.
787
+ * @throws {SidebarError} pty-error when the per-session cap is reached.
788
+ */
789
+ open(sessionId, tabId, cwd, cols, rows) {
790
+ const key = `${sessionId}:${tabId}`;
791
+ this.cancelClose(key);
792
+ const existing = this.sessions.get(key);
793
+ if (existing !== void 0 && !existing.exited && existing.cwd === cwd) return existing;
794
+ if (existing !== void 0) this.close(key);
795
+ for (const [candidate, handle] of [...this.sessions]) if (handle.sessionId === sessionId && handle.exited) this.close(candidate);
796
+ if (this.keysOf(sessionId).length >= this.maxPerSession) throw new SidebarError("pty-error", `terminal limit reached (${this.maxPerSession}) for this session`, 400);
797
+ const handle = {
798
+ key,
799
+ sessionId,
800
+ tabId,
801
+ cwd,
802
+ pty: nodePty.spawn(this.shell, [], {
803
+ name: "xterm-256color",
804
+ cols: Math.max(2, Math.floor(cols)),
805
+ rows: Math.max(2, Math.floor(rows)),
806
+ cwd,
807
+ env: { ...process.env }
808
+ }),
809
+ transcript: "",
810
+ exited: false
811
+ };
812
+ handle.pty.onData((data) => {
813
+ handle.transcript += data;
814
+ if (handle.transcript.length > TRANSCRIPT_LIMIT$1) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT$1);
815
+ });
816
+ handle.pty.onExit(({ exitCode }) => {
817
+ handle.exited = true;
818
+ handle.exitCode = exitCode;
819
+ });
820
+ this.sessions.set(key, handle);
821
+ return handle;
822
+ }
823
+ /**
824
+ * Schedule the terminal's destruction after `delayMs`. A tab close sends
825
+ * delay 0 (release the quota immediately); a bare socket drop (refresh,
826
+ * crash) uses the grace period so a quick reconnect keeps the process.
827
+ * `open()` cancels any pending close.
828
+ */
829
+ scheduleClose(key, delayMs) {
830
+ if (this.sessions.get(key) === void 0) return;
831
+ this.cancelClose(key);
832
+ const timer = setTimeout(() => {
833
+ this.close(key);
834
+ }, delayMs);
835
+ this.pendingCloses.set(key, timer);
836
+ }
837
+ /** Cancel a pending scheduled close (the terminal is being reopened). */
838
+ cancelClose(key) {
839
+ const timer = this.pendingCloses.get(key);
840
+ if (timer !== void 0) {
841
+ clearTimeout(timer);
842
+ this.pendingCloses.delete(key);
843
+ }
844
+ }
845
+ /** Resolve a live handle by key, or undefined. */
846
+ get(key) {
847
+ return this.sessions.get(key);
848
+ }
849
+ /** Close a terminal and drop its state (the owning tab was closed). */
850
+ close(key) {
851
+ this.cancelClose(key);
852
+ const handle = this.sessions.get(key);
853
+ if (handle === void 0) return;
854
+ this.sessions.delete(key);
855
+ try {
856
+ handle.pty.kill();
857
+ } catch {}
858
+ }
859
+ /** Close every terminal (plugin teardown). */
860
+ disposeAll() {
861
+ for (const timer of this.pendingCloses.values()) clearTimeout(timer);
862
+ this.pendingCloses.clear();
863
+ for (const key of [...this.sessions.keys()]) this.close(key);
864
+ }
865
+ };
866
+ /** The interactive shell for this platform (empty SHELL falls back). */
867
+ function defaultShell() {
868
+ if (process.platform === "win32") return "powershell.exe";
869
+ const shell = process.env.SHELL;
870
+ return shell !== void 0 && shell.trim() !== "" ? shell : "/bin/bash";
871
+ }
872
+ //#endregion
873
+ //#region src/agent-pty.ts
874
+ /**
875
+ * Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
876
+ * sessions created by the model through the `terminal_create` tool. Each
877
+ * handle survives across tool calls (and across WebSocket disconnects from
878
+ * the sidebar view) until the model calls `terminal_close` or the user
879
+ * closes the corresponding sidebar tab — tmux semantics, scoped per agent
880
+ * session.
881
+ *
882
+ * This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
883
+ * `${sessionId}:${tabId}` and capped per session, while agent terminals are
884
+ * keyed by uuid and uncapped (the model is trusted to close unused ones).
885
+ * Both registries share the same shell resolver and spawn-helper fix.
886
+ */
887
+ /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
888
+ const TRANSCRIPT_LIMIT = 1 << 20;
889
+ /** POSIX signals the registry forwards to a live pty. */
890
+ const ALLOWED_SIGNALS = [
891
+ "SIGINT",
892
+ "SIGTERM",
893
+ "SIGKILL",
894
+ "SIGHUP",
895
+ "SIGTSTP"
896
+ ];
897
+ /** Largest pty dimension the registry accepts (mirrors the tool contract). */
898
+ const TERMINAL_DIM_MAX = 1024;
899
+ /** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
900
+ function clampDims(cols, rows) {
901
+ const clamp = (value) => Math.min(TERMINAL_DIM_MAX, Math.max(2, Math.floor(value)));
902
+ return {
903
+ cols: clamp(cols),
904
+ rows: clamp(rows)
905
+ };
906
+ }
907
+ /** Map a POSIX signal number to its conventional name (best-effort). */
908
+ const SIGNAL_NAMES = {
909
+ 1: "SIGHUP",
910
+ 2: "SIGINT",
911
+ 3: "SIGQUIT",
912
+ 4: "SIGILL",
913
+ 6: "SIGABRT",
914
+ 9: "SIGKILL",
915
+ 11: "SIGSEGV",
916
+ 13: "SIGPIPE",
917
+ 14: "SIGALRM",
918
+ 15: "SIGTERM",
919
+ 17: "SIGCHLD",
920
+ 18: "SIGCONT",
921
+ 19: "SIGSTOP",
922
+ 20: "SIGTSTP"
923
+ };
924
+ /** Convert a raw signal number to a name (or null when absent/unknown). */
925
+ function signalNameOf(signal) {
926
+ if (signal === null || signal === void 0) return null;
927
+ return SIGNAL_NAMES[signal] ?? `signal ${signal}`;
928
+ }
929
+ /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
930
+ function locateNeedle(transcript, needle) {
931
+ if (needle === "") return void 0;
932
+ const idx = transcript.indexOf(needle);
933
+ if (idx === -1) return void 0;
934
+ let line = 0;
935
+ let lineStart = 0;
936
+ for (let i = 0; i < idx; i += 1) if (transcript.charCodeAt(i) === 10) {
937
+ line += 1;
938
+ lineStart = i + 1;
939
+ }
940
+ return {
941
+ line,
942
+ column: idx - lineStart
943
+ };
944
+ }
945
+ /** Snapshot projection of a handle (drops the pty reference and transcript). */
946
+ function snapshotOf(handle) {
947
+ const out = {
948
+ uuid: handle.uuid,
949
+ title: handle.title,
950
+ command: handle.command,
951
+ exited: handle.exited
952
+ };
953
+ if (handle.exited) {
954
+ out.exitCode = handle.exitCode ?? null;
955
+ out.exitSignal = signalNameOf(handle.exitSignal);
956
+ }
957
+ return out;
958
+ }
959
+ /**
960
+ * The agent terminal registry. The constructor takes the resolved shell
961
+ * binary (the same `defaultShell()` the UI-tab registry uses) and runs the
962
+ * spawn-helper chmod fix once at construction so the first agent terminal
963
+ * does not race a lazy fixer.
964
+ */
965
+ var AgentPtyRegistry = class {
966
+ shell;
967
+ sessions = /* @__PURE__ */ new Map();
968
+ changeListeners = /* @__PURE__ */ new Set();
969
+ constructor(shell) {
970
+ this.shell = shell;
971
+ ensureSpawnHelper();
972
+ }
973
+ /**
974
+ * Spawn one agent terminal: start the shell in `cwd`, then write
975
+ * `command + '\n'` to stdin so the command runs in the fresh shell. The
976
+ * terminal stays alive after the command exits — the model can send more
977
+ * input through `terminal_send` until it calls `terminal_close` or the
978
+ * user closes the sidebar tab. An empty `command` spawns a bare shell.
979
+ * @returns the new handle's uuid (the model-facing opaque id).
980
+ */
981
+ create(sessionId, title, command, cwd, cols = 80, rows = 24) {
982
+ const uuid = randomUUID();
983
+ const dims = clampDims(cols, rows);
984
+ const pty = nodePty.spawn(this.shell, [], {
985
+ name: "xterm-256color",
986
+ cols: dims.cols,
987
+ rows: dims.rows,
988
+ cwd,
989
+ env: { ...process.env }
990
+ });
991
+ const handle = {
992
+ uuid,
993
+ sessionId,
994
+ title,
995
+ command,
996
+ cwd,
997
+ pty,
998
+ transcript: "",
999
+ exited: false
1000
+ };
1001
+ pty.onData((data) => {
1002
+ handle.transcript += data;
1003
+ if (handle.transcript.length > TRANSCRIPT_LIMIT) handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT);
1004
+ });
1005
+ pty.onExit(({ exitCode, signal }) => {
1006
+ handle.exited = true;
1007
+ handle.exitCode = exitCode;
1008
+ handle.exitSignal = signal;
1009
+ this.notify();
1010
+ });
1011
+ if (command !== "") try {
1012
+ pty.write(`${command}\r`);
1013
+ } catch {}
1014
+ this.sessions.set(uuid, handle);
1015
+ this.notify();
1016
+ return uuid;
1017
+ }
1018
+ /** All live agent terminals belonging to one conversation. */
1019
+ list(sessionId) {
1020
+ const out = [];
1021
+ for (const handle of this.sessions.values()) if (handle.sessionId === sessionId) out.push(snapshotOf(handle));
1022
+ return out;
1023
+ }
1024
+ /** Resolve a live handle by uuid, or throw `not-found`. */
1025
+ expect(uuid) {
1026
+ const handle = this.sessions.get(uuid);
1027
+ if (handle === void 0) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
1028
+ return handle;
1029
+ }
1030
+ /**
1031
+ * Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
1032
+ * The model-facing tools call this before every uuid-keyed operation: a
1033
+ * uuid from another session is indistinguishable from an unknown one, so a
1034
+ * model can never reach (or probe) a terminal it does not own.
1035
+ */
1036
+ assertOwned(uuid, sessionId) {
1037
+ const handle = this.expect(uuid);
1038
+ if (handle.sessionId !== sessionId) throw new SidebarError("not-found", `agent terminal "${uuid}" not found`, 404);
1039
+ return handle;
1040
+ }
1041
+ /** Resolve a handle's snapshot, or undefined if it does not exist. */
1042
+ snapshot(uuid) {
1043
+ const handle = this.sessions.get(uuid);
1044
+ return handle === void 0 ? void 0 : snapshotOf(handle);
1045
+ }
1046
+ /** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
1047
+ send(uuid, text) {
1048
+ const handle = this.expect(uuid);
1049
+ if (handle.exited) throw new SidebarError("bad-request", `agent terminal "${uuid}" has exited`, 400);
1050
+ handle.pty.write(text);
1051
+ }
1052
+ /**
1053
+ * Read one bounded page of the retained transcript. `offset` is a 0-based
1054
+ * line index from the start of the retained transcript (default 0);
1055
+ * `count` caps the page size (default 500). A negative `offset` reads
1056
+ * from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
1057
+ * so the model can paginate.
1058
+ */
1059
+ read(uuid, offset, count) {
1060
+ const lines = this.expect(uuid).transcript.split("\n");
1061
+ const totalLines = lines.length;
1062
+ const pageSize = Math.max(1, Math.min(count ?? 500, 500));
1063
+ let start;
1064
+ if (offset === void 0 || offset === 0) start = 0;
1065
+ else if (offset < 0) start = Math.max(0, totalLines + offset);
1066
+ else start = Math.min(offset, totalLines);
1067
+ const end = Math.min(start + pageSize, totalLines);
1068
+ return {
1069
+ text: lines.slice(start, end).join("\n"),
1070
+ totalLines,
1071
+ lineBegin: start,
1072
+ lineEnd: end
1073
+ };
1074
+ }
1075
+ /**
1076
+ * Resize a terminal's pty, clamped to the 2..1024 sane range.
1077
+ * @returns the dimensions actually applied (the caller echoes these, so the
1078
+ * reported value always matches the pty).
1079
+ */
1080
+ resize(uuid, cols, rows) {
1081
+ const handle = this.expect(uuid);
1082
+ const dims = clampDims(cols, rows);
1083
+ if (!handle.exited) handle.pty.resize(dims.cols, dims.rows);
1084
+ return dims;
1085
+ }
1086
+ /**
1087
+ * Wait for `needle` to appear in a terminal's transcript, or for the
1088
+ * terminal to exit, or for the timeout to elapse — whichever happens
1089
+ * first. The wait polls the live transcript every ~50ms and short-circuits
1090
+ * on `signal` abort (re-thrown as the abort reason so the tool layer
1091
+ * surfaces cancellation).
1092
+ *
1093
+ * The match scans the FULL retained transcript on each poll, not just the
1094
+ * delta since the last poll — a needle that scrolled past the most recent
1095
+ * chunk but is still within the ~1 MiB bound is still a match. The
1096
+ * returned line/column locate the FIRST occurrence (oldest), which is what
1097
+ * a user watching the terminal would have seen first.
1098
+ *
1099
+ * The implementation uses polling (not pty onData subscription) because
1100
+ * node-pty's onData fires before the registry's own onData listener
1101
+ * updates the transcript (listener order is not guaranteed), and on
1102
+ * Windows ConPTY output can arrive in bursts with batching delays that
1103
+ * make event-driven wakeups unreliable. A 50ms poll is fast enough for
1104
+ * interactive use and simple enough to be obviously correct.
1105
+ * @param uuid - terminal to watch.
1106
+ * @param needle - substring to search for (case-sensitive, verbatim).
1107
+ * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
1108
+ * @param signal - caller-owned cancellation; aborts the wait re-throwing.
1109
+ * @returns one of `found` / `timeout` / `exited`.
1110
+ */
1111
+ async waitFor(uuid, needle, timeoutMs = 1e4, signal) {
1112
+ if (needle === "") throw new SidebarError("bad-request", "needle must be a non-empty string", 400);
1113
+ const handle = this.expect(uuid);
1114
+ const timeout = Math.max(100, Math.floor(timeoutMs));
1115
+ const start = Date.now();
1116
+ const deadline = start + timeout;
1117
+ if (handle.exited) return {
1118
+ kind: "exited",
1119
+ needle,
1120
+ exitCode: handle.exitCode ?? null,
1121
+ exitSignal: signalNameOf(handle.exitSignal)
1122
+ };
1123
+ const firstHit = locateNeedle(handle.transcript, needle);
1124
+ if (firstHit !== void 0) return {
1125
+ kind: "found",
1126
+ needle,
1127
+ line: firstHit.line,
1128
+ column: firstHit.column,
1129
+ elapsedMs: Date.now() - start
1130
+ };
1131
+ while (true) {
1132
+ if (signal?.aborted) signal.throwIfAborted();
1133
+ if (handle.exited) return {
1134
+ kind: "exited",
1135
+ needle,
1136
+ exitCode: handle.exitCode ?? null,
1137
+ exitSignal: signalNameOf(handle.exitSignal)
1138
+ };
1139
+ const hit = locateNeedle(handle.transcript, needle);
1140
+ if (hit !== void 0) return {
1141
+ kind: "found",
1142
+ needle,
1143
+ line: hit.line,
1144
+ column: hit.column,
1145
+ elapsedMs: Date.now() - start
1146
+ };
1147
+ if (Date.now() >= deadline) return {
1148
+ kind: "timeout",
1149
+ needle,
1150
+ timeoutMs: timeout,
1151
+ totalLines: handle.transcript.split("\n").length
1152
+ };
1153
+ await new Promise((resolve) => {
1154
+ const t = setTimeout(resolve, 50);
1155
+ if (typeof t === "object" && "unref" in t) t.unref();
1156
+ });
1157
+ }
1158
+ }
1159
+ /**
1160
+ * Send a POSIX signal to a terminal's foreground process.
1161
+ *
1162
+ * Two delivery paths, by signal kind:
1163
+ * - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
1164
+ * writing the corresponding control character to the pty stdin. This is
1165
+ * how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
1166
+ * line discipline (POSIX ISIG mode) or the ConPTY input pipeline
1167
+ * (Windows), which translates it into a SIGINT/SIGTSTP for the
1168
+ * foreground process group. This works on every platform — calling
1169
+ * `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
1170
+ * but writing `\x03` is universally correct.
1171
+ * - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
1172
+ * which maps to the platform's process-termination path (POSIX
1173
+ * `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
1174
+ * control characters.
1175
+ */
1176
+ signal(uuid, signal) {
1177
+ const handle = this.expect(uuid);
1178
+ if (handle.exited) return;
1179
+ if (signal === "SIGINT" || signal === "SIGTSTP") {
1180
+ const ctrlByte = signal === "SIGINT" ? "" : "";
1181
+ try {
1182
+ handle.pty.write(ctrlByte);
1183
+ } catch {}
1184
+ return;
1185
+ }
1186
+ try {
1187
+ handle.pty.kill(signal);
1188
+ } catch {
1189
+ try {
1190
+ handle.pty.kill();
1191
+ } catch {}
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Close a terminal and drop its state. Idempotent: a second close of the
1196
+ * same uuid is a no-op. Returns true iff a live handle was actually
1197
+ * dropped.
1198
+ */
1199
+ close(uuid) {
1200
+ const handle = this.sessions.get(uuid);
1201
+ if (handle === void 0) return false;
1202
+ this.sessions.delete(uuid);
1203
+ try {
1204
+ handle.pty.kill();
1205
+ } catch {}
1206
+ this.notify();
1207
+ return true;
1208
+ }
1209
+ /** Resolve a live handle by uuid (for the WS attach path). */
1210
+ get(uuid) {
1211
+ return this.sessions.get(uuid);
1212
+ }
1213
+ /**
1214
+ * Subscribe to registry changes (create / close / exit). The sidebar push
1215
+ * endpoint uses this to forward snapshots to the connected view. Returns
1216
+ * the unsubscribe function.
1217
+ */
1218
+ subscribe(listener) {
1219
+ this.changeListeners.add(listener);
1220
+ return () => {
1221
+ this.changeListeners.delete(listener);
1222
+ };
1223
+ }
1224
+ /** Close every agent terminal (plugin teardown). */
1225
+ disposeAll() {
1226
+ for (const uuid of [...this.sessions.keys()]) this.close(uuid);
1227
+ }
1228
+ /** Fire every change listener (callers wrap in try/catch if needed). */
1229
+ notify() {
1230
+ for (const listener of [...this.changeListeners]) try {
1231
+ listener();
1232
+ } catch {}
1233
+ }
1234
+ };
1235
+ //#endregion
1236
+ //#region src/tools.ts
1237
+ /**
1238
+ * Eight model-facing tools for the agent-owned sidebar terminals (tmux
1239
+ * semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
1240
+ * close, list). Each tool binds to the calling agent's session through
1241
+ * `exec.agent.session.id`, so the model never passes a sessionId — the
1242
+ * agent identity is the scope.
1243
+ *
1244
+ * Conventions (per plugin-development-guide.md §3):
1245
+ * C1 — parameters schema-validated before `execute` runs.
1246
+ * C4 — `execute` returns one canonical JSON value; `render` is a separate
1247
+ * pure text projection.
1248
+ * C6 — `exec.signal.throwIfAborted()` before any spawn.
1249
+ * C10 — no UI/transport vocabulary in the canonical value.
1250
+ */
1251
+ /** Maximum UTF-8 bytes of one `terminal_read` result text. */
1252
+ const READ_BYTE_LIMIT = 262144;
1253
+ /**
1254
+ * Bound a string to a byte limit, marking truncation. Truncation never
1255
+ * splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
1256
+ * the walk-back retreats to the sequence's leading byte so the retained
1257
+ * prefix decodes cleanly (a split would decode to U+FFFD).
1258
+ * @internal exported for the unit tests, like {@link snapshotOf}.
1259
+ */
1260
+ function boundBytes(text, maxBytes) {
1261
+ const buf = Buffer.from(text, "utf8");
1262
+ if (buf.byteLength <= maxBytes) return {
1263
+ text,
1264
+ truncated: false
1265
+ };
1266
+ let end = maxBytes;
1267
+ while (end > 0 && ((buf[end] ?? 0) & 192) === 128) end -= 1;
1268
+ return {
1269
+ text: buf.subarray(0, end).toString("utf8"),
1270
+ truncated: true
1271
+ };
1272
+ }
1273
+ /** Pure text projection helper (the canonical value is already structured). */
1274
+ function textRender(fn) {
1275
+ return (_args, value) => [{
1276
+ type: "text",
1277
+ text: fn(value)
1278
+ }];
1279
+ }
1280
+ /** Extract the calling agent or throw the canonical "no agent" error. */
1281
+ function requireAgent(agent) {
1282
+ if (agent === void 0) throw new Error("sidebar terminal tools require an initiating agent");
1283
+ return agent;
1284
+ }
1285
+ /** Resolve the calling agent's session id (the registry scope + ownership key). */
1286
+ function sessionIdOf(exec) {
1287
+ return requireAgent(exec.agent).session.id;
1288
+ }
1289
+ /**
1290
+ * Register the eight terminal tools against the host tool registry. The
1291
+ * `resolveCwd` callback threads the live session cwd (authoritative from the
1292
+ * session store, falling back to the process cwd) so a freshly-created
1293
+ * terminal lands in the right directory without the model passing it.
1294
+ * Every uuid-keyed tool first asserts the terminal belongs to the calling
1295
+ * session (`registry.assertOwned`), so one agent can never reach another
1296
+ * session's terminals.
1297
+ * @param ctx - host plugin context (carries the tools service).
1298
+ * @param registry - the agent-owned terminal registry.
1299
+ * @param resolveCwd - live cwd resolver for one session id.
1300
+ * @returns a disposer that unregisters all eight tools (the caller gates
1301
+ * registration on the side-card setting and calls this to turn them off).
1302
+ */
1303
+ function registerTools(ctx, registry, resolveCwd) {
1304
+ const disposers = [];
1305
+ const register = (tool) => {
1306
+ disposers.push(ctx.tools.register(tool));
1307
+ };
1308
+ register(defineTool({
1309
+ name: "terminal_create",
1310
+ 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.",
1311
+ parameters: {
1312
+ title: {
1313
+ type: "string",
1314
+ required: true,
1315
+ description: "Short human-readable label for the terminal tab (e.g. \"dev server\", \"python repl\")."
1316
+ },
1317
+ command: {
1318
+ type: "string",
1319
+ required: true,
1320
+ 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."
1321
+ }
1322
+ },
1323
+ output: {
1324
+ schema: {
1325
+ type: "object",
1326
+ additionalProperties: false,
1327
+ properties: {
1328
+ uuid: {
1329
+ type: "string",
1330
+ required: true,
1331
+ description: "Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close."
1332
+ },
1333
+ title: {
1334
+ type: "string",
1335
+ required: true,
1336
+ description: "The title you provided (echoed for confirmation)."
1337
+ }
1338
+ }
1339
+ },
1340
+ 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.`)
1341
+ },
1342
+ execute: (args, exec) => {
1343
+ exec.signal.throwIfAborted();
1344
+ const sessionId = sessionIdOf(exec);
1345
+ const cwd = resolveCwd(sessionId);
1346
+ const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24);
1347
+ return Promise.resolve({
1348
+ uuid,
1349
+ title: args.title
1350
+ });
1351
+ }
1352
+ }));
1353
+ register(defineTool({
1354
+ name: "terminal_list",
1355
+ 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.",
1356
+ parameters: {},
1357
+ output: {
1358
+ schema: {
1359
+ type: "array",
1360
+ items: {
1361
+ type: "object",
1362
+ additionalProperties: false,
1363
+ properties: {
1364
+ uuid: {
1365
+ type: "string",
1366
+ required: true
1367
+ },
1368
+ title: {
1369
+ type: "string",
1370
+ required: true
1371
+ },
1372
+ command: {
1373
+ type: "string",
1374
+ required: true
1375
+ },
1376
+ exited: {
1377
+ type: "boolean",
1378
+ required: true
1379
+ },
1380
+ exitCode: { oneOf: [{ type: "integer" }, { type: "null" }] },
1381
+ exitSignal: { oneOf: [{ type: "string" }, { type: "null" }] }
1382
+ }
1383
+ }
1384
+ },
1385
+ render: (_args, value) => {
1386
+ const list = value;
1387
+ if (list.length === 0) return [{
1388
+ type: "text",
1389
+ text: "No agent terminals open in this session."
1390
+ }];
1391
+ return [{
1392
+ type: "text",
1393
+ text: `Agent terminals in this session:\n${list.map((t) => {
1394
+ const status = t.exited ? `exited (code ${t.exitCode ?? "?"}, signal ${t.exitSignal ?? "none"})` : "running";
1395
+ return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`;
1396
+ }).join("\n")}`
1397
+ }];
1398
+ }
1399
+ },
1400
+ execute: (_args, exec) => {
1401
+ const sessionId = sessionIdOf(exec);
1402
+ return Promise.resolve(registry.list(sessionId));
1403
+ }
1404
+ }));
1405
+ register(defineTool({
1406
+ name: "terminal_send",
1407
+ 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.",
1408
+ parameters: {
1409
+ uuid: {
1410
+ type: "string",
1411
+ required: true,
1412
+ description: "Terminal uuid from terminal_create or terminal_list."
1413
+ },
1414
+ text: {
1415
+ type: "string",
1416
+ required: true,
1417
+ description: "UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead."
1418
+ },
1419
+ submit: {
1420
+ type: "boolean",
1421
+ 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."
1422
+ }
1423
+ },
1424
+ output: {
1425
+ schema: {
1426
+ type: "object",
1427
+ additionalProperties: false,
1428
+ properties: {
1429
+ uuid: {
1430
+ type: "string",
1431
+ required: true
1432
+ },
1433
+ bytes: {
1434
+ type: "integer",
1435
+ required: true,
1436
+ description: "Number of UTF-8 bytes written (including the Enter key if submit was true)."
1437
+ }
1438
+ }
1439
+ },
1440
+ render: textRender((v) => `Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`)
1441
+ },
1442
+ execute: (args, exec) => {
1443
+ exec.signal.throwIfAborted();
1444
+ const sessionId = sessionIdOf(exec);
1445
+ registry.assertOwned(args.uuid, sessionId);
1446
+ const payload = args.submit === true ? `${args.text}\r` : args.text;
1447
+ registry.send(args.uuid, payload);
1448
+ return Promise.resolve({
1449
+ uuid: args.uuid,
1450
+ bytes: Buffer.byteLength(payload, "utf8")
1451
+ });
1452
+ }
1453
+ }));
1454
+ register(defineTool({
1455
+ name: "terminal_read",
1456
+ 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.",
1457
+ parameters: {
1458
+ uuid: {
1459
+ type: "string",
1460
+ required: true,
1461
+ description: "Terminal uuid from terminal_create or terminal_list."
1462
+ },
1463
+ offset: {
1464
+ type: "number",
1465
+ 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)."
1466
+ },
1467
+ count: {
1468
+ type: "number",
1469
+ description: "Maximum lines to return (default 500, hard cap 500)."
1470
+ }
1471
+ },
1472
+ output: {
1473
+ schema: {
1474
+ type: "object",
1475
+ additionalProperties: false,
1476
+ properties: {
1477
+ text: {
1478
+ type: "string",
1479
+ required: true,
1480
+ description: "The slice of transcript for the requested page."
1481
+ },
1482
+ totalLines: {
1483
+ type: "integer",
1484
+ required: true,
1485
+ description: "Total lines in the retained transcript."
1486
+ },
1487
+ lineBegin: {
1488
+ type: "integer",
1489
+ required: true,
1490
+ description: "0-based index of the first line in `text` (inclusive)."
1491
+ },
1492
+ lineEnd: {
1493
+ type: "integer",
1494
+ required: true,
1495
+ description: "0-based index of the last line in `text` (exclusive)."
1496
+ },
1497
+ truncated: {
1498
+ type: "boolean",
1499
+ required: true,
1500
+ description: "Whether `text` was truncated to fit the 256 KiB read cap."
1501
+ }
1502
+ }
1503
+ },
1504
+ render: (_args, value) => {
1505
+ const v = value;
1506
+ return [{
1507
+ type: "text",
1508
+ text: `${`[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? "; truncated to 256KiB" : ""}]`}\n${v.text}`
1509
+ }];
1510
+ }
1511
+ },
1512
+ execute: (args, exec) => {
1513
+ exec.signal.throwIfAborted();
1514
+ const sessionId = sessionIdOf(exec);
1515
+ registry.assertOwned(args.uuid, sessionId);
1516
+ const result = registry.read(args.uuid, args.offset, args.count);
1517
+ const bounded = boundBytes(result.text, READ_BYTE_LIMIT);
1518
+ return Promise.resolve({
1519
+ text: bounded.text,
1520
+ totalLines: result.totalLines,
1521
+ lineBegin: result.lineBegin,
1522
+ lineEnd: result.lineEnd,
1523
+ truncated: bounded.truncated
1524
+ });
1525
+ }
1526
+ }));
1527
+ register(defineTool({
1528
+ name: "terminal_wait_for",
1529
+ 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.",
1530
+ parameters: {
1531
+ uuid: {
1532
+ type: "string",
1533
+ required: true,
1534
+ description: "Terminal uuid from terminal_create or terminal_list."
1535
+ },
1536
+ needle: {
1537
+ type: "string",
1538
+ required: true,
1539
+ description: "Substring to wait for (case-sensitive, verbatim). Must be non-empty."
1540
+ },
1541
+ timeout_ms: {
1542
+ type: "number",
1543
+ description: "Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms."
1544
+ }
1545
+ },
1546
+ output: {
1547
+ schema: { oneOf: [
1548
+ {
1549
+ type: "object",
1550
+ additionalProperties: false,
1551
+ properties: {
1552
+ kind: {
1553
+ type: "string",
1554
+ required: true,
1555
+ const: "found"
1556
+ },
1557
+ needle: {
1558
+ type: "string",
1559
+ required: true
1560
+ },
1561
+ line: {
1562
+ type: "integer",
1563
+ required: true,
1564
+ description: "0-based line index in the retained transcript where the needle first appeared."
1565
+ },
1566
+ column: {
1567
+ type: "integer",
1568
+ required: true,
1569
+ description: "0-based column index within that line where the match starts."
1570
+ },
1571
+ elapsedMs: {
1572
+ type: "integer",
1573
+ required: true,
1574
+ description: "Wall-clock milliseconds from wait start to match."
1575
+ }
1576
+ }
1577
+ },
1578
+ {
1579
+ type: "object",
1580
+ additionalProperties: false,
1581
+ properties: {
1582
+ kind: {
1583
+ type: "string",
1584
+ required: true,
1585
+ const: "timeout"
1586
+ },
1587
+ needle: {
1588
+ type: "string",
1589
+ required: true
1590
+ },
1591
+ timeoutMs: {
1592
+ type: "integer",
1593
+ required: true,
1594
+ description: "The configured timeout that elapsed."
1595
+ },
1596
+ totalLines: {
1597
+ type: "integer",
1598
+ required: true,
1599
+ description: "Total lines retained when the timeout fired. Call terminal_read to inspect the tail."
1600
+ }
1601
+ }
1602
+ },
1603
+ {
1604
+ type: "object",
1605
+ additionalProperties: false,
1606
+ properties: {
1607
+ kind: {
1608
+ type: "string",
1609
+ required: true,
1610
+ const: "exited"
1611
+ },
1612
+ needle: {
1613
+ type: "string",
1614
+ required: true
1615
+ },
1616
+ exitCode: {
1617
+ oneOf: [{ type: "integer" }, { type: "null" }],
1618
+ description: "Exit code, if known."
1619
+ },
1620
+ exitSignal: {
1621
+ oneOf: [{ type: "string" }, { type: "null" }],
1622
+ description: "Exit signal name, if killed by a signal."
1623
+ }
1624
+ }
1625
+ }
1626
+ ] },
1627
+ render: (_args, value) => {
1628
+ const v = value;
1629
+ if (v.kind === "found") return [{
1630
+ type: "text",
1631
+ text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).`
1632
+ }];
1633
+ if (v.kind === "timeout") return [{
1634
+ type: "text",
1635
+ text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.`
1636
+ }];
1637
+ const exitInfo = v.exitCode !== void 0 && v.exitCode !== null ? ` (exit code ${v.exitCode})` : "";
1638
+ return [{
1639
+ type: "text",
1640
+ text: `Terminal exited before "${v.needle}" appeared${exitInfo}.`
1641
+ }];
1642
+ }
1643
+ },
1644
+ async execute(args, exec) {
1645
+ exec.signal.throwIfAborted();
1646
+ const sessionId = sessionIdOf(exec);
1647
+ registry.assertOwned(args.uuid, sessionId);
1648
+ const timeoutMs = args.timeout_ms ?? 1e4;
1649
+ return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal);
1650
+ }
1651
+ }));
1652
+ register(defineTool({
1653
+ name: "terminal_resize",
1654
+ 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.",
1655
+ parameters: {
1656
+ uuid: {
1657
+ type: "string",
1658
+ required: true,
1659
+ description: "Terminal uuid from terminal_create or terminal_list."
1660
+ },
1661
+ cols: {
1662
+ type: "integer",
1663
+ required: true,
1664
+ description: "New column count ( clamped to 2..1024 )."
1665
+ },
1666
+ rows: {
1667
+ type: "integer",
1668
+ required: true,
1669
+ description: "New row count ( clamped to 2..1024 )."
1670
+ }
1671
+ },
1672
+ output: {
1673
+ schema: {
1674
+ type: "object",
1675
+ additionalProperties: false,
1676
+ properties: {
1677
+ uuid: {
1678
+ type: "string",
1679
+ required: true
1680
+ },
1681
+ cols: {
1682
+ type: "integer",
1683
+ required: true
1684
+ },
1685
+ rows: {
1686
+ type: "integer",
1687
+ required: true
1688
+ }
1689
+ }
1690
+ },
1691
+ render: textRender((v) => `Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`)
1692
+ },
1693
+ execute: (args, exec) => {
1694
+ exec.signal.throwIfAborted();
1695
+ const sessionId = sessionIdOf(exec);
1696
+ registry.assertOwned(args.uuid, sessionId);
1697
+ const dims = registry.resize(args.uuid, args.cols, args.rows);
1698
+ return Promise.resolve({
1699
+ uuid: args.uuid,
1700
+ ...dims
1701
+ });
1702
+ }
1703
+ }));
1704
+ register(defineTool({
1705
+ name: "terminal_signal",
1706
+ 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.",
1707
+ parameters: {
1708
+ uuid: {
1709
+ type: "string",
1710
+ required: true,
1711
+ description: "Terminal uuid from terminal_create or terminal_list."
1712
+ },
1713
+ signal: {
1714
+ type: "string",
1715
+ required: true,
1716
+ enum: ALLOWED_SIGNALS,
1717
+ description: "Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z)."
1718
+ }
1719
+ },
1720
+ output: {
1721
+ schema: {
1722
+ type: "object",
1723
+ additionalProperties: false,
1724
+ properties: {
1725
+ uuid: {
1726
+ type: "string",
1727
+ required: true
1728
+ },
1729
+ signal: {
1730
+ type: "string",
1731
+ required: true
1732
+ }
1733
+ }
1734
+ },
1735
+ render: textRender((v) => `Sent ${v.signal} to terminal ${v.uuid}.`)
1736
+ },
1737
+ execute: (args, exec) => {
1738
+ exec.signal.throwIfAborted();
1739
+ const sessionId = sessionIdOf(exec);
1740
+ registry.assertOwned(args.uuid, sessionId);
1741
+ registry.signal(args.uuid, args.signal);
1742
+ return Promise.resolve({
1743
+ uuid: args.uuid,
1744
+ signal: args.signal
1745
+ });
1746
+ }
1747
+ }));
1748
+ register(defineTool({
1749
+ name: "terminal_close",
1750
+ 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.",
1751
+ parameters: { uuid: {
1752
+ type: "string",
1753
+ required: true,
1754
+ description: "Terminal uuid from terminal_create or terminal_list."
1755
+ } },
1756
+ output: {
1757
+ schema: {
1758
+ type: "object",
1759
+ additionalProperties: false,
1760
+ properties: {
1761
+ uuid: {
1762
+ type: "string",
1763
+ required: true
1764
+ },
1765
+ closed: {
1766
+ type: "boolean",
1767
+ required: true,
1768
+ description: "Whether a live terminal was actually dropped (false if the uuid was already gone)."
1769
+ }
1770
+ }
1771
+ },
1772
+ render: textRender((v) => v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`)
1773
+ },
1774
+ execute: (args, exec) => {
1775
+ exec.signal.throwIfAborted();
1776
+ const sessionId = sessionIdOf(exec);
1777
+ registry.assertOwned(args.uuid, sessionId);
1778
+ const closed = registry.close(args.uuid);
1779
+ return Promise.resolve({
1780
+ uuid: args.uuid,
1781
+ closed
1782
+ });
1783
+ }
1784
+ }));
1785
+ return () => {
1786
+ for (const dispose of disposers) dispose();
1787
+ };
1788
+ }
1789
+ //#endregion
1790
+ //#region src/jobs-routes.ts
1791
+ /**
1792
+ * Extract the plain text of a finalized tool result: the text blocks inside
1793
+ * the 'tool-result' block, joined with newlines. Error results and
1794
+ * non-text blocks contribute nothing.
1795
+ */
1796
+ function resultText(message) {
1797
+ if (!Array.isArray(message.content)) return void 0;
1798
+ const parts = [];
1799
+ for (const block of message.content) {
1800
+ if (block === null || typeof block !== "object") continue;
1801
+ const candidate = block;
1802
+ if (candidate.type !== "tool-result") continue;
1803
+ const inner = candidate.content;
1804
+ if (!Array.isArray(inner)) continue;
1805
+ for (const item of inner) {
1806
+ if (item === null || typeof item !== "object") continue;
1807
+ const textItem = item;
1808
+ if (textItem.type === "text" && typeof textItem.text === "string") parts.push(textItem.text);
1809
+ }
1810
+ }
1811
+ return parts.length > 0 ? parts.join("\n") : void 0;
1812
+ }
1813
+ /** Whether a tool/result is an error result (the inner block's isError flag). */
1814
+ function resultIsError(message) {
1815
+ if (!Array.isArray(message.content)) return false;
1816
+ return message.content.some((block) => {
1817
+ if (block === null || typeof block !== "object") return false;
1818
+ return block.type === "tool-result" && block.isError === true;
1819
+ });
1820
+ }
1821
+ /** Whether a job_output result carries no new output — the controller's
1822
+ * model-facing "(no new output)" body, noise for the human pane. */
1823
+ function isNoNewOutput(text) {
1824
+ return text.startsWith("(no new output)");
1825
+ }
1826
+ /** Extract the job_output trace of one raw session event (undefined = unrelated). */
1827
+ function traceOf(event) {
1828
+ if (event.type === "tool/call") {
1829
+ const data = event.data;
1830
+ if (data.name !== "job_output" || typeof data.callId !== "string") return void 0;
1831
+ let jobId;
1832
+ try {
1833
+ const args = JSON.parse(typeof data.arguments === "string" ? data.arguments : "");
1834
+ if (typeof args.job_id === "string") jobId = args.job_id;
1835
+ } catch {}
1836
+ if (jobId === void 0) return void 0;
1837
+ return {
1838
+ seq: event.seq,
1839
+ kind: "call",
1840
+ callId: data.callId,
1841
+ jobId
1842
+ };
1843
+ }
1844
+ if (event.type === "tool/result") {
1845
+ const message = event.data.message;
1846
+ if (message === void 0) return void 0;
1847
+ const callId = message.source?.callId;
1848
+ if (typeof callId !== "string") return void 0;
1849
+ return {
1850
+ seq: event.seq,
1851
+ kind: "result",
1852
+ callId,
1853
+ text: resultText(message),
1854
+ isError: resultIsError(message)
1855
+ };
1856
+ }
1857
+ }
1858
+ /** Per-session cap of mirrored live traces (a bounded, lossy ring). */
1859
+ const MIRROR_MAX_ENTRIES = 200;
1860
+ /**
1861
+ * The live job_output mirror: subscribes to the session append feed and
1862
+ * caches the job_output traces the session store's own log can lag behind
1863
+ * (after a host restart the store session stays frozen at its rehydration
1864
+ * boundary, so `session.events` misses everything appended since — the very
1865
+ * reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
1866
+ * same feed to browsers.
1867
+ */
1868
+ function createJobOutputMirror(ctx) {
1869
+ const perSession = /* @__PURE__ */ new Map();
1870
+ const callIds = /* @__PURE__ */ new Map();
1871
+ if (typeof ctx.on !== "function") return { entries: () => [] };
1872
+ const dispose = ctx.on("session/event", (session, event) => {
1873
+ const sessionId = session?.id;
1874
+ if (typeof sessionId !== "string") return;
1875
+ if (event.type === "tool/call") {
1876
+ const trace = traceOf(event);
1877
+ if (trace?.kind !== "call") return;
1878
+ let ids = callIds.get(sessionId);
1879
+ if (ids === void 0) callIds.set(sessionId, ids = /* @__PURE__ */ new Set());
1880
+ ids.add(trace.callId);
1881
+ push(sessionId, trace);
1882
+ } else if (event.type === "tool/result") {
1883
+ const trace = traceOf(event);
1884
+ if (trace?.kind !== "result") return;
1885
+ if (!callIds.get(sessionId)?.has(trace.callId)) return;
1886
+ push(sessionId, trace);
1887
+ }
1888
+ });
1889
+ ctx.effect(() => dispose, "dsh-better-sidebar: job-output event mirror");
1890
+ const push = (sessionId, trace) => {
1891
+ let list = perSession.get(sessionId);
1892
+ if (list === void 0) perSession.set(sessionId, list = []);
1893
+ list.push(trace);
1894
+ if (list.length > MIRROR_MAX_ENTRIES) {
1895
+ const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES);
1896
+ const ids = callIds.get(sessionId);
1897
+ if (ids !== void 0) {
1898
+ for (const entry of removed) if (entry.kind === "call") ids.delete(entry.callId);
1899
+ if (ids.size === 0) callIds.delete(sessionId);
1900
+ }
1901
+ }
1902
+ };
1903
+ return { entries: (sessionId) => perSession.get(sessionId) ?? [] };
1904
+ }
1905
+ /**
1906
+ * Build the jobs routes bound to the plugin context. `output` merges the
1907
+ * owner session's own event log with the live job_output mirror; `kill`
1908
+ * reads the jobs/agents services lazily and degrades to a 503 when the
1909
+ * deployment lacks the registry.
1910
+ * @param ctx - host plugin context.
1911
+ * @param outputLimit - response cap for one output replay in bytes; longer
1912
+ * texts are sliced and flagged `truncated` (mirrors the fs.read cap).
1913
+ */
1914
+ function buildJobsApi(ctx, outputLimit) {
1915
+ const jobs = ctx.get("jobs");
1916
+ const agents = ctx.get("agents");
1917
+ const mirror = createJobOutputMirror(ctx);
1918
+ /** The live caller whose session id the registry fence compares against. */
1919
+ const callerOf = (sessionId) => agents?.get(sessionId);
1920
+ /** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
1921
+ const registryError = (error) => new SidebarError("job-error", error instanceof Error ? error.message : String(error), 404);
1922
+ return {
1923
+ output(payload) {
1924
+ const sessionId = requireString(payload, "sessionId");
1925
+ const id = requireString(payload, "id");
1926
+ const bySeq = /* @__PURE__ */ new Map();
1927
+ for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
1928
+ const trace = traceOf(event);
1929
+ if (trace !== void 0) bySeq.set(trace.seq, trace);
1930
+ }
1931
+ for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace);
1932
+ const jobOf = /* @__PURE__ */ new Map();
1933
+ const parts = [];
1934
+ let read = false;
1935
+ for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) if (trace.kind === "call") {
1936
+ if (trace.jobId !== void 0) jobOf.set(trace.callId, trace.jobId);
1937
+ } else if (jobOf.get(trace.callId) === id) {
1938
+ read = true;
1939
+ if (trace.isError !== true && trace.text !== void 0 && !isNoNewOutput(trace.text)) parts.push(trace.text);
1940
+ }
1941
+ const text = parts.join("\n");
1942
+ return {
1943
+ text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
1944
+ truncated: text.length > outputLimit,
1945
+ read
1946
+ };
1947
+ },
1948
+ kill(payload) {
1949
+ if (jobs === void 0) throw new SidebarError("job-error", "the background-job registry is not mounted in this deployment", 503);
1950
+ const sessionId = requireString(payload, "sessionId");
1951
+ const id = requireString(payload, "id");
1952
+ const record = payload;
1953
+ const reason = typeof record?.reason === "string" && record.reason !== "" ? record.reason : "user requested via sidebar";
1954
+ try {
1955
+ return {
1956
+ ok: true,
1957
+ outcome: jobs.kill(id, callerOf(sessionId), reason)
1958
+ };
1959
+ } catch (error) {
1960
+ throw registryError(error);
1961
+ }
1962
+ }
1963
+ };
1964
+ }
1965
+ //#endregion
1966
+ //#region src/index.ts
1967
+ /**
1968
+ * dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
1969
+ * read/write, git), the /sidebar/file media route (images), the /sidebar/html
1970
+ * preview route, the /sidebar/bundle lazy-chunk route (client code splits),
1971
+ * and the terminal WebSocket upgrade. Every route passes the same
1972
+ * browser-trust fence as the /api gateway — Host-header loopback or the
1973
+ * connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
1974
+ * literals per boot) — with the trustedHosts read live from the connection
1975
+ * loader row so the fence never drifts from the deployment's.
1976
+ *
1977
+ * All operations are conversation-scoped: requests carry a sessionId, the
1978
+ * session's authoritative cwd comes from the session store, and terminal
1979
+ * processes are keyed by session.
1980
+ */
1981
+ /** Plugin identity for cordis.yml rows. */
1982
+ const name = "dsh-better-sidebar";
1983
+ /** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
1984
+ const inject = [
1985
+ "webServer",
1986
+ "sessions",
1987
+ "loader",
1988
+ "tools"
1989
+ ];
1990
+ /** Content types for the media route, by extension. */
1991
+ const MEDIA_TYPES = {
1992
+ ".png": "image/png",
1993
+ ".jpg": "image/jpeg",
1994
+ ".jpeg": "image/jpeg",
1995
+ ".gif": "image/gif",
1996
+ ".webp": "image/webp",
1997
+ ".svg": "image/svg+xml",
1998
+ ".bmp": "image/bmp",
1999
+ ".ico": "image/x-icon",
2000
+ ".avif": "image/avif",
2001
+ ".pdf": "application/pdf",
2002
+ ".html": "text/html",
2003
+ ".htm": "text/html"
2004
+ };
2005
+ /** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
2006
+ function mediaTypeForPath(path) {
2007
+ return MEDIA_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
2008
+ }
2009
+ /** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
2010
+ function trustedHostsOf(ctx) {
2011
+ for (const entry of ctx.loader.entries()) if (entry.options.name === "connection") return entry.options.config?.trustedHosts ?? [];
2012
+ return [];
2013
+ }
2014
+ /**
2015
+ * Resolve a session's authoritative working directory. The attached session
2016
+ * header wins; while the session is still hydrating from persistence (the
2017
+ * web client attaches the current conversation a moment after page load, so
2018
+ * the very first sidebar requests can arrive detached) the caller's own
2019
+ * list-summary cwd is used; the process cwd is the last resort (blank
2020
+ * sessions have no cwd anywhere yet). Never throws for a missing cwd, so
2021
+ * explorer/git/terminal work from first paint instead of surfacing
2022
+ * "session ... has no working directory".
2023
+ */
2024
+ function sessionCwdOf(ctx, sessionId, clientCwd) {
2025
+ const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
2026
+ if (headerCwd !== void 0 && headerCwd !== "") return headerCwd;
2027
+ if (clientCwd !== void 0 && clientCwd !== "") try {
2028
+ return requireAbsolute(clientCwd);
2029
+ } catch {
2030
+ throw new SidebarError("bad-request", `invalid working directory "${clientCwd}"`);
2031
+ }
2032
+ return process.cwd();
2033
+ }
2034
+ /**
2035
+ * Resolve a path that a git command reported — `git status`/`git diff`
2036
+ * print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
2037
+ * session cwd (a session inside a subdirectory of a repository). Absolute
2038
+ * paths pass through; relative ones join the repo root (falling back to the
2039
+ * cwd when the root cannot be resolved, e.g. a bare directory).
2040
+ */
2041
+ async function resolveGitPath(cwd, raw) {
2042
+ if (isAbsolute(raw)) return requireAbsolute(raw);
2043
+ const root = await repoRoot(cwd).catch(() => cwd);
2044
+ return requireAbsolute(join(root, raw));
2045
+ }
2046
+ /** How many leading bytes a binary read returns for client-side detect sniffing. */
2047
+ const READ_HEAD_LIMIT = 4096;
2048
+ /** Text read of a file with the size cap; binary detection via NUL probe.
2049
+ * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
2050
+ * so the client can re-match viewers by content (`detect`). */
2051
+ async function readText(path, readLimit) {
2052
+ const info = await stat(path).catch((error) => {
2053
+ throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
2054
+ });
2055
+ if (info.isDirectory()) throw new SidebarError("fs-error", `"${path}" is a directory`, 400);
2056
+ const size = info.size;
2057
+ const truncated = size > readLimit;
2058
+ const handle = await open(path, "r").catch((error) => {
2059
+ throw new SidebarError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
2060
+ });
2061
+ try {
2062
+ const buffer = Buffer.alloc(Math.min(size, readLimit));
2063
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
2064
+ const slice = buffer.subarray(0, bytesRead);
2065
+ const binary = slice.includes(0);
2066
+ const head = binary ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString("base64") : void 0;
2067
+ return {
2068
+ content: binary ? "" : slice.toString("utf8"),
2069
+ truncated,
2070
+ binary,
2071
+ size,
2072
+ head
2073
+ };
2074
+ } finally {
2075
+ await handle.close();
2076
+ }
2077
+ }
2078
+ /** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
2079
+ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, getSettings) {
2080
+ const cwdOf = (payload) => {
2081
+ const sessionId = requireString(payload, "sessionId");
2082
+ const record = payload;
2083
+ return {
2084
+ sessionId,
2085
+ cwd: sessionCwdOf(ctx, sessionId, typeof record?.cwd === "string" && record.cwd !== "" ? record.cwd : void 0)
2086
+ };
2087
+ };
2088
+ const jobsApi = buildJobsApi(ctx, resolved.readLimit);
2089
+ return {
2090
+ "session.cwd": (payload) => {
2091
+ const { sessionId, cwd } = cwdOf(payload);
2092
+ return {
2093
+ sessionId,
2094
+ cwd,
2095
+ root: rootLabel(cwd),
2096
+ parent: parentOf(cwd) ?? null
2097
+ };
2098
+ },
2099
+ "fs.tree": async (payload) => {
2100
+ const { cwd } = cwdOf(payload);
2101
+ return listDirectory(payload.path === void 0 ? cwd : requireAbsolute(requireString(payload, "path")), resolved.listLimit);
2102
+ },
2103
+ "fs.read": async (payload) => {
2104
+ const { cwd } = cwdOf(payload);
2105
+ const { content, truncated, binary, size, head } = await readText(await resolveGitPath(cwd, requireString(payload, "path")), resolved.readLimit);
2106
+ if (binary) return {
2107
+ kind: "binary",
2108
+ size,
2109
+ truncated,
2110
+ head
2111
+ };
2112
+ return {
2113
+ kind: "text",
2114
+ content,
2115
+ truncated
2116
+ };
2117
+ },
2118
+ "fs.write": async (payload) => {
2119
+ const { cwd } = cwdOf(payload);
2120
+ const path = requireAbsolute(requireString(payload, "path"));
2121
+ const content = requireString(payload, "content");
2122
+ const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`;
2123
+ try {
2124
+ await mkdir(dirname(path), { recursive: true });
2125
+ await writeFile(tmp, content, "utf8");
2126
+ await rename(tmp, path);
2127
+ } catch (error) {
2128
+ await rm(tmp, { force: true }).catch(() => {});
2129
+ throw new SidebarError("fs-error", `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
2130
+ }
2131
+ return { ok: true };
2132
+ },
2133
+ "git.status": async (payload) => {
2134
+ const { cwd } = cwdOf(payload);
2135
+ return status(cwd);
2136
+ },
2137
+ "git.diff": async (payload) => {
2138
+ const { cwd } = cwdOf(payload);
2139
+ const record = payload;
2140
+ return { diff: await diff(cwd, record.path === void 0 ? void 0 : await resolveGitPath(cwd, requireString(payload, "path")), record.staged === true) };
2141
+ },
2142
+ "git.stage": async (payload) => {
2143
+ const { cwd } = cwdOf(payload);
2144
+ await stage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"));
2145
+ return { ok: true };
2146
+ },
2147
+ "git.unstage": async (payload) => {
2148
+ const { cwd } = cwdOf(payload);
2149
+ await unstage(cwd, payload.path === void 0 ? void 0 : requireString(payload, "path"));
2150
+ return { ok: true };
2151
+ },
2152
+ "git.commit": async (payload) => {
2153
+ const { cwd } = cwdOf(payload);
2154
+ await commit(cwd, requireString(payload, "message"));
2155
+ return { ok: true };
2156
+ },
2157
+ "git.branch": async (payload) => {
2158
+ const { cwd } = cwdOf(payload);
2159
+ return branches(cwd);
2160
+ },
2161
+ "git.checkout": async (payload) => {
2162
+ const { cwd } = cwdOf(payload);
2163
+ await checkout(cwd, requireString(payload, "branch"));
2164
+ return { ok: true };
2165
+ },
2166
+ "git.log": async (payload) => {
2167
+ const { cwd } = cwdOf(payload);
2168
+ const record = payload;
2169
+ 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);
2170
+ },
2171
+ "git.commit-diff": async (payload) => {
2172
+ const { cwd } = cwdOf(payload);
2173
+ return { diff: await commitDiff(cwd, requireString(payload, "hash")) };
2174
+ },
2175
+ "git.discard": async (payload) => {
2176
+ const { cwd } = cwdOf(payload);
2177
+ await discard(cwd, await resolveGitPath(cwd, requireString(payload, "path")));
2178
+ return { ok: true };
2179
+ },
2180
+ "git.revert": async (payload) => {
2181
+ const { cwd } = cwdOf(payload);
2182
+ await revert(cwd, requireString(payload, "hash"));
2183
+ return { ok: true };
2184
+ },
2185
+ "git.cherry-pick": async (payload) => {
2186
+ const { cwd } = cwdOf(payload);
2187
+ await cherryPick(cwd, requireString(payload, "hash"));
2188
+ return { ok: true };
2189
+ },
2190
+ "git.show": async (payload) => {
2191
+ const { cwd } = cwdOf(payload);
2192
+ const path = await resolveGitPath(cwd, requireString(payload, "path"));
2193
+ return { content: await show(cwd, requireString(payload, "rev"), path) };
2194
+ },
2195
+ "pty.close": (payload) => {
2196
+ const sessionId = requireString(payload, "sessionId");
2197
+ const tab = requireString(payload, "tab");
2198
+ ptyManager.close(`${sessionId}:${tab}`);
2199
+ return { ok: true };
2200
+ },
2201
+ "agent-pty.close": (payload) => {
2202
+ const uuid = requireString(payload, "uuid");
2203
+ agentPtyRegistry.close(uuid);
2204
+ return { ok: true };
2205
+ },
2206
+ "jobs.output": (payload) => jobsApi.output(payload),
2207
+ "jobs.kill": (payload) => jobsApi.kill(payload),
2208
+ "settings.get": () => {
2209
+ return getSettings()?.get() ?? {
2210
+ value: void 0,
2211
+ revision: void 0
2212
+ };
2213
+ },
2214
+ "settings.update": async (payload) => {
2215
+ const settings = getSettings();
2216
+ if (settings === void 0) throw new SidebarError("settings-rejected", "the settings service is not mounted in this deployment", 503);
2217
+ const record = payload;
2218
+ const patch = record?.patch;
2219
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) throw new SidebarError("bad-request", "patch must be a plain object");
2220
+ const expectedRevision = typeof record?.expectedRevision === "number" ? record.expectedRevision : void 0;
2221
+ try {
2222
+ return await settings.update(patch, expectedRevision);
2223
+ } catch (error) {
2224
+ if (error instanceof SettingsConflictError) throw new SidebarError("settings-conflict", error.message, 409);
2225
+ throw new SidebarError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
2226
+ }
2227
+ },
2228
+ "browser.probe": async (payload) => {
2229
+ const raw = requireString(payload, "url");
2230
+ let parsed;
2231
+ try {
2232
+ parsed = new URL(raw);
2233
+ } catch {
2234
+ throw new SidebarError("bad-request", "invalid url", 400);
2235
+ }
2236
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new SidebarError("bad-request", "only http/https urls can be probed", 400);
2237
+ if (isLoopbackHostname(parsed.hostname)) throw new SidebarError("bad-request", "local addresses are not probed", 400);
2238
+ const controller = new AbortController();
2239
+ const timer = setTimeout(() => controller.abort(), 8e3);
2240
+ try {
2241
+ let response = await fetch(parsed, {
2242
+ method: "HEAD",
2243
+ redirect: "follow",
2244
+ signal: controller.signal
2245
+ });
2246
+ if (response.status === 405 || response.status === 501) response = await fetch(parsed, {
2247
+ method: "GET",
2248
+ redirect: "follow",
2249
+ signal: controller.signal
2250
+ });
2251
+ const frameAncestors = extractFrameAncestors(response.headers.get("content-security-policy"));
2252
+ const xFrameOptions = response.headers.get("x-frame-options");
2253
+ return {
2254
+ reachable: true,
2255
+ url: response.url,
2256
+ status: response.status,
2257
+ ...xFrameOptions !== null ? { xFrameOptions } : {},
2258
+ ...frameAncestors !== void 0 ? { frameAncestors } : {}
2259
+ };
2260
+ } catch {
2261
+ return { reachable: false };
2262
+ } finally {
2263
+ clearTimeout(timer);
2264
+ }
2265
+ }
2266
+ };
2267
+ }
2268
+ /**
2269
+ * Plugin body: mount the fenced routes and the pty lifecycle.
2270
+ * @param ctx - host plugin context (webServer, sessions, loader).
2271
+ * @param config - deployment-provided limits; the Loader validates against
2272
+ * {@link Config} and fills defaults, direct callers get them from
2273
+ * {@link resolveSidebarConfig}.
2274
+ */
2275
+ function apply(ctx, config) {
2276
+ ensureSpawnHelper();
2277
+ const resolved = resolveSidebarConfig(config);
2278
+ const trustedHosts = trustedHostsOf(ctx);
2279
+ const fence = (req) => isTrustedApiRequest(req, trustedHosts);
2280
+ const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession);
2281
+ const agentPtyRegistry = new AgentPtyRegistry(defaultShell());
2282
+ let settingsFace;
2283
+ let toolsDisposers = null;
2284
+ const syncToolsGate = (scope) => {
2285
+ if (scope.get().agentTerminalTools) {
2286
+ if (toolsDisposers === null) toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId));
2287
+ } else if (toolsDisposers !== null) {
2288
+ toolsDisposers();
2289
+ toolsDisposers = null;
2290
+ agentPtyRegistry.disposeAll();
2291
+ }
2292
+ };
2293
+ ctx.inject(["settings"], (sctx) => {
2294
+ const ns = settingsNamespace(SIDEBAR_PREFS_NS);
2295
+ const scope = sctx.settings.register(ns, PrefsSchema);
2296
+ const viewOf = () => {
2297
+ const descriptor = sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
2298
+ return descriptor === void 0 ? {
2299
+ value: void 0,
2300
+ revision: void 0
2301
+ } : {
2302
+ value: descriptor.value,
2303
+ revision: descriptor.revision
2304
+ };
2305
+ };
2306
+ settingsFace = {
2307
+ get: viewOf,
2308
+ update: async (patch, expectedRevision) => {
2309
+ await sctx.settings.update(ns, patch, expectedRevision);
2310
+ return viewOf();
2311
+ }
2312
+ };
2313
+ syncToolsGate(scope);
2314
+ scope.watch(() => {
2315
+ syncToolsGate(scope);
2316
+ });
2317
+ });
2318
+ const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace);
2319
+ ctx.effect(() => ctx.webServer.register({
2320
+ kind: "prefix",
2321
+ path: "/sidebar/api",
2322
+ handler: async (req, res) => {
2323
+ if (!fence(req)) {
2324
+ writeJson(res, 403, {
2325
+ ok: false,
2326
+ error: {
2327
+ code: "forbidden",
2328
+ message: "forbidden"
2329
+ }
2330
+ });
2331
+ return;
2332
+ }
2333
+ if (req.method !== "POST") {
2334
+ writeJson(res, 405, {
2335
+ ok: false,
2336
+ error: {
2337
+ code: "method-error",
2338
+ message: "method not allowed"
2339
+ }
2340
+ });
2341
+ return;
2342
+ }
2343
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
2344
+ const method = pathname.startsWith("/sidebar/api/") ? pathname.slice(13) : void 0;
2345
+ if (method === void 0 || method.includes("/")) {
2346
+ writeError(res, new SidebarError("not-found", "unknown sidebar API method", 404));
2347
+ return;
2348
+ }
2349
+ try {
2350
+ const payload = await readJsonBody(req);
2351
+ const handler = api[method];
2352
+ if (handler === void 0) throw new SidebarError("not-found", `unknown sidebar API method "${method}"`, 404);
2353
+ writeOk(res, await handler(payload));
2354
+ } catch (error) {
2355
+ writeError(res, error);
2356
+ }
2357
+ }
2358
+ }), "dsh-better-sidebar: /sidebar/api routes");
2359
+ ctx.effect(() => registerBundleRoute(ctx, fence), "dsh-better-sidebar: /sidebar/bundle chunk route");
2360
+ ctx.effect(() => ctx.webServer.register({
2361
+ kind: "prefix",
2362
+ path: "/sidebar/file",
2363
+ handler: async (req, res) => {
2364
+ if (!fence(req)) {
2365
+ res.writeHead(403);
2366
+ res.end("forbidden");
2367
+ return;
2368
+ }
2369
+ if (req.method !== "GET") {
2370
+ res.writeHead(405);
2371
+ res.end();
2372
+ return;
2373
+ }
2374
+ try {
2375
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
2376
+ const sessionId = url.searchParams.get("sessionId");
2377
+ const raw = url.searchParams.get("path");
2378
+ if (sessionId === null || raw === null) throw new SidebarError("bad-request", "sessionId and path are required");
2379
+ const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
2380
+ const path = requireAbsolute(raw);
2381
+ if (!isWithin(cwd, path)) throw new SidebarError("fs-error", "media path outside the session working directory", 403);
2382
+ const info = await stat(path);
2383
+ if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
2384
+ const type = mediaTypeForPath(path);
2385
+ const body = await readFile(path);
2386
+ const headers = {
2387
+ "content-type": type,
2388
+ "cache-control": "no-cache"
2389
+ };
2390
+ if (url.searchParams.get("download") === "1") headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`;
2391
+ res.writeHead(200, headers);
2392
+ res.end(body);
2393
+ } catch (error) {
2394
+ writeError(res, error);
2395
+ }
2396
+ }
2397
+ }), "dsh-better-sidebar: /sidebar/file media route");
2398
+ ctx.effect(() => ctx.webServer.register({
2399
+ kind: "prefix",
2400
+ path: "/sidebar/html",
2401
+ handler: async (req, res) => {
2402
+ if (!fence(req)) {
2403
+ res.writeHead(403);
2404
+ res.end("forbidden");
2405
+ return;
2406
+ }
2407
+ if (req.method !== "GET") {
2408
+ res.writeHead(405);
2409
+ res.end();
2410
+ return;
2411
+ }
2412
+ try {
2413
+ const decoded = decodeHtmlUrl(new URL(req.url ?? "/", "http://dsh.internal").pathname);
2414
+ if (!decoded.ok) {
2415
+ writeError(res, new SidebarError("bad-request", decoded.message, decoded.status));
2416
+ return;
2417
+ }
2418
+ const { sessionId, path } = decoded.ref;
2419
+ const cwd = sessionCwdOf(ctx, sessionId);
2420
+ const absolute = requireAbsolute(path);
2421
+ if (!isWithin(cwd, absolute)) throw new SidebarError("fs-error", "html path outside the session working directory", 403);
2422
+ const info = await stat(absolute);
2423
+ if (!info.isFile() || info.size > resolved.mediaLimit) throw new SidebarError("fs-error", "not a file or too large", 400);
2424
+ const type = mediaTypeForPath(absolute);
2425
+ const body = await readFile(absolute);
2426
+ res.writeHead(200, {
2427
+ "content-type": type,
2428
+ "cache-control": "no-cache",
2429
+ "x-content-type-options": "nosniff",
2430
+ "referrer-policy": "no-referrer",
2431
+ "content-security-policy": "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'"
2432
+ });
2433
+ res.end(body);
2434
+ } catch (error) {
2435
+ writeError(res, error);
2436
+ }
2437
+ }
2438
+ }), "dsh-better-sidebar: /sidebar/html preview route");
2439
+ const wss = new WebSocketServer({ noServer: true });
2440
+ ctx.effect(() => ctx.webServer.registerUpgrade({
2441
+ path: "/sidebar/ws/terminal",
2442
+ handler: (req, socket, head) => {
2443
+ if (!fence(req)) {
2444
+ socket.destroy();
2445
+ return;
2446
+ }
2447
+ wss.handleUpgrade(req, socket, head, (ws) => {
2448
+ attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved);
2449
+ });
2450
+ }
2451
+ }), "dsh-better-sidebar: terminal WebSocket");
2452
+ const agentListWss = new WebSocketServer({ noServer: true });
2453
+ ctx.effect(() => ctx.webServer.registerUpgrade({
2454
+ path: "/sidebar/ws/agent-terminals",
2455
+ handler: (req, socket, head) => {
2456
+ if (!fence(req)) {
2457
+ socket.destroy();
2458
+ return;
2459
+ }
2460
+ agentListWss.handleUpgrade(req, socket, head, (ws) => {
2461
+ attachAgentList(agentPtyRegistry, ws, req);
2462
+ });
2463
+ }
2464
+ }), "dsh-better-sidebar: agent-terminals push WebSocket");
2465
+ ctx.effect(() => () => {
2466
+ toolsDisposers?.();
2467
+ ptyManager.disposeAll();
2468
+ agentPtyRegistry.disposeAll();
2469
+ wss.close();
2470
+ agentListWss.close();
2471
+ }, "dsh-better-sidebar: teardown");
2472
+ }
2473
+ /** Push the live agent-terminal list for one session to a connected sidebar view. */
2474
+ async function attachAgentList(registry, ws, req) {
2475
+ try {
2476
+ const sessionId = new URL(req.url ?? "/", "http://dsh.internal").searchParams.get("sessionId");
2477
+ if (sessionId === null) {
2478
+ ws.close(1008, "sessionId is required");
2479
+ return;
2480
+ }
2481
+ const send = () => {
2482
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(registry.list(sessionId)));
2483
+ };
2484
+ send();
2485
+ const unsubscribe = registry.subscribe(send);
2486
+ ws.on("close", () => {
2487
+ unsubscribe();
2488
+ });
2489
+ ws.on("error", () => {
2490
+ unsubscribe();
2491
+ });
2492
+ } catch (error) {
2493
+ ws.close(1011, error instanceof Error ? error.message : String(error));
2494
+ }
2495
+ }
2496
+ /**
2497
+ * Wire one terminal socket to its pty: replay transcript, pump both ways.
2498
+ * Two attach modes share the wire protocol:
2499
+ * - `?uuid=...` attaches to an agent-owned terminal (created by the
2500
+ * `terminal_create` tool). The close frame kills the pty immediately
2501
+ * (the agent's terminal closes when the user closes the sidebar tab); a
2502
+ * bare socket drop (refresh, tab switch) leaves the pty alive for the
2503
+ * reconnect grace, exactly like UI-tab terminals.
2504
+ * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
2505
+ * created it from the + menu). The close frame schedules a 0-ms close
2506
+ * (the host's reconnect grace keeps the shell alive across a refresh).
2507
+ */
2508
+ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved) {
2509
+ try {
2510
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
2511
+ const uuid = url.searchParams.get("uuid");
2512
+ if (uuid !== null) {
2513
+ const handle = agentPtyRegistry.get(uuid);
2514
+ if (handle === void 0) {
2515
+ ws.close(1011, `agent terminal "${uuid}" not found`);
2516
+ return;
2517
+ }
2518
+ pumpAgentTerminal(agentPtyRegistry, handle, ws);
2519
+ return;
2520
+ }
2521
+ const sessionId = url.searchParams.get("sessionId");
2522
+ const tabId = url.searchParams.get("tab");
2523
+ if (sessionId === null || tabId === null) {
2524
+ ws.close(1008, "either ?uuid or ?sessionId+?tab are required");
2525
+ return;
2526
+ }
2527
+ const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
2528
+ const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24);
2529
+ if (handle.transcript !== "") ws.send(handle.transcript);
2530
+ const onData = (data) => {
2531
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
2532
+ };
2533
+ const onExit = ({ exitCode }) => {
2534
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
2535
+ };
2536
+ const dataSub = handle.pty.onData(onData);
2537
+ const exitSub = handle.pty.onExit(onExit);
2538
+ ws.on("message", (data) => {
2539
+ const text = data.toString("utf8");
2540
+ let control = null;
2541
+ try {
2542
+ const parsed = JSON.parse(text);
2543
+ if (parsed !== null && typeof parsed === "object") control = parsed;
2544
+ } catch {}
2545
+ if (control !== null && control.type === "close") {
2546
+ ptyManager.scheduleClose(handle.key, 0);
2547
+ return;
2548
+ }
2549
+ if (handle.exited) return;
2550
+ if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
2551
+ const dims = clampDims(control.cols, control.rows);
2552
+ handle.pty.resize(dims.cols, dims.rows);
2553
+ } else handle.pty.write(text);
2554
+ });
2555
+ ws.on("close", () => {
2556
+ dataSub.dispose();
2557
+ exitSub.dispose();
2558
+ ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs);
2559
+ });
2560
+ } catch (error) {
2561
+ ws.close(1011, error instanceof Error ? error.message : String(error));
2562
+ }
2563
+ }
2564
+ /**
2565
+ * Pump one agent terminal's pty to a connected view. The close frame kills
2566
+ * the pty immediately (the agent's terminal closes when the user closes the
2567
+ * sidebar tab); a bare socket drop leaves the pty alive — the agent owns
2568
+ * the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
2569
+ * plugin teardown kills it.
2570
+ */
2571
+ function pumpAgentTerminal(registry, handle, ws) {
2572
+ if (handle.transcript !== "") ws.send(handle.transcript);
2573
+ const onData = (data) => {
2574
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
2575
+ };
2576
+ const onExit = ({ exitCode }) => {
2577
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`);
2578
+ };
2579
+ const dataSub = handle.pty.onData(onData);
2580
+ const exitSub = handle.pty.onExit(onExit);
2581
+ ws.on("message", (data) => {
2582
+ if (handle.exited) return;
2583
+ const text = data.toString("utf8");
2584
+ let control = null;
2585
+ try {
2586
+ const parsed = JSON.parse(text);
2587
+ if (parsed !== null && typeof parsed === "object") control = parsed;
2588
+ } catch {}
2589
+ if (control !== null && control.type === "close") {
2590
+ registry.close(handle.uuid);
2591
+ return;
2592
+ }
2593
+ if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") {
2594
+ const dims = clampDims(control.cols, control.rows);
2595
+ handle.pty.resize(dims.cols, dims.rows);
2596
+ } else if (control === null) handle.pty.write(text);
2597
+ });
2598
+ ws.on("close", () => {
2599
+ dataSub.dispose();
2600
+ exitSub.dispose();
2601
+ });
2602
+ }
2603
+ //#endregion
2604
+ export { Config, apply, inject, mediaTypeForPath, name };