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/src/index.ts ADDED
@@ -0,0 +1,897 @@
1
+ /**
2
+ * dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
3
+ * read/write, git), the /sidebar/file media route (images), the /sidebar/html
4
+ * preview route, the /sidebar/bundle lazy-chunk route (client code splits),
5
+ * and the terminal WebSocket upgrade. Every route passes the same
6
+ * browser-trust fence as the /api gateway — Host-header loopback or the
7
+ * connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
8
+ * literals per boot) — with the trustedHosts read live from the connection
9
+ * loader row so the fence never drifts from the deployment's.
10
+ *
11
+ * All operations are conversation-scoped: requests carry a sessionId, the
12
+ * session's authoritative cwd comes from the session store, and terminal
13
+ * processes are keyed by session.
14
+ */
15
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
16
+ import { basename, dirname, extname, isAbsolute, join } from 'node:path'
17
+ import type { IncomingMessage } from 'node:http'
18
+ import { WebSocket, WebSocketServer } from 'ws'
19
+ import type { Context } from './context-types.ts'
20
+ import {
21
+ Config,
22
+ PrefsSchema,
23
+ resolveSidebarConfig,
24
+ SIDEBAR_PREFS_NS,
25
+ type ResolvedSidebarConfig,
26
+ type SidebarConfig,
27
+ type SidebarPrefs,
28
+ } from './config.ts'
29
+ import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
30
+ import { decodeHtmlUrl } from './html-route.ts'
31
+ import { extractFrameAncestors } from './browser-probe.ts'
32
+ import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
33
+ import { registerBundleRoute } from './bundle-route.ts'
34
+ import * as git from './git.ts'
35
+ import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
36
+ import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
37
+ import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
38
+ import { registerTools } from './tools.ts'
39
+ import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
40
+ import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
41
+
42
+ export { Config }
43
+ export type { SidebarConfig, ResolvedSidebarConfig }
44
+ // Re-export the Context augmentation (declare module 'cordis') so consumers
45
+ // `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`.
46
+ // Also re-export the service descriptor types so consumers can type their
47
+ // registerTab / registerFileViewer arguments without reaching into /client.
48
+ export type { Context } from './context-types.ts'
49
+ export type {
50
+ BetterSidebarService,
51
+ TabDescriptor,
52
+ TabComponentProps,
53
+ FileViewerDescriptor,
54
+ FileViewerProps,
55
+ FileFetchStrategy,
56
+ } from './client/service.ts'
57
+
58
+ /** Plugin identity for cordis.yml rows. */
59
+ export const name = 'dsh-better-sidebar'
60
+
61
+ /** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
62
+ export const inject = ['webServer', 'sessions', 'loader', 'tools']
63
+
64
+ /** Content types for the media route, by extension. */
65
+ const MEDIA_TYPES: Record<string, string> = {
66
+ '.png': 'image/png',
67
+ '.jpg': 'image/jpeg',
68
+ '.jpeg': 'image/jpeg',
69
+ '.gif': 'image/gif',
70
+ '.webp': 'image/webp',
71
+ '.svg': 'image/svg+xml',
72
+ '.bmp': 'image/bmp',
73
+ '.ico': 'image/x-icon',
74
+ '.avif': 'image/avif',
75
+ '.pdf': 'application/pdf',
76
+ '.html': 'text/html',
77
+ '.htm': 'text/html',
78
+ }
79
+
80
+ /** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
81
+ export function mediaTypeForPath(path: string): string {
82
+ return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
83
+ }
84
+
85
+ /** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
86
+ function trustedHostsOf(ctx: Context): string[] {
87
+ for (const entry of ctx.loader.entries()) {
88
+ if (entry.options.name === 'connection') {
89
+ const config = entry.options.config as { trustedHosts?: string[] } | undefined
90
+ return config?.trustedHosts ?? []
91
+ }
92
+ }
93
+ return []
94
+ }
95
+
96
+ /**
97
+ * Resolve a session's authoritative working directory. The attached session
98
+ * header wins; while the session is still hydrating from persistence (the
99
+ * web client attaches the current conversation a moment after page load, so
100
+ * the very first sidebar requests can arrive detached) the caller's own
101
+ * list-summary cwd is used; the process cwd is the last resort (blank
102
+ * sessions have no cwd anywhere yet). Never throws for a missing cwd, so
103
+ * explorer/git/terminal work from first paint instead of surfacing
104
+ * "session ... has no working directory".
105
+ */
106
+ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
107
+ const session = ctx.sessions.get(sessionId)
108
+ const headerCwd = session?.header.cwd
109
+ if (headerCwd !== undefined && headerCwd !== '') return headerCwd
110
+ if (clientCwd !== undefined && clientCwd !== '') {
111
+ try {
112
+ return requireAbsolute(clientCwd)
113
+ } catch {
114
+ throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
115
+ }
116
+ }
117
+ return process.cwd()
118
+ }
119
+
120
+ /**
121
+ * Resolve a path that a git command reported — `git status`/`git diff`
122
+ * print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
123
+ * session cwd (a session inside a subdirectory of a repository). Absolute
124
+ * paths pass through; relative ones join the repo root (falling back to the
125
+ * cwd when the root cannot be resolved, e.g. a bare directory).
126
+ */
127
+ async function resolveGitPath(cwd: string, raw: string): Promise<string> {
128
+ if (isAbsolute(raw)) return requireAbsolute(raw)
129
+ const root = await git.repoRoot(cwd).catch(() => cwd)
130
+ return requireAbsolute(join(root, raw))
131
+ }
132
+
133
+ /** How many leading bytes a binary read returns for client-side detect sniffing. */
134
+ const READ_HEAD_LIMIT = 4096
135
+
136
+ /** Text read of a file with the size cap; binary detection via NUL probe.
137
+ * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
138
+ * so the client can re-match viewers by content (`detect`). */
139
+ async function readText(path: string, readLimit: number): Promise<{
140
+ content: string
141
+ truncated: boolean
142
+ binary: boolean
143
+ size: number
144
+ head?: string
145
+ }> {
146
+ const info = await stat(path).catch((error: unknown) => {
147
+ throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
148
+ })
149
+ if (info.isDirectory()) {
150
+ throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
151
+ }
152
+ const size = info.size
153
+ const truncated = size > readLimit
154
+ const handle = await open(path, 'r').catch((error: unknown) => {
155
+ throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
156
+ })
157
+ try {
158
+ const buffer = Buffer.alloc(Math.min(size, readLimit))
159
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
160
+ const slice = buffer.subarray(0, bytesRead)
161
+ const binary = slice.includes(0)
162
+ const head = binary
163
+ ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
164
+ : undefined
165
+ return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
166
+ } finally {
167
+ await handle.close()
168
+ }
169
+ }
170
+
171
+ /** One API method dispatch table entry. */
172
+ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
173
+
174
+ /**
175
+ * The live face of the side card settings namespace, bound to the settings
176
+ * service when it is mounted. The DSH settings RPC domain only serves
177
+ * allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
178
+ * and writes THIS namespace through the plugin's own fenced /sidebar routes,
179
+ * which call the seam in-process — no configuration-client gate involved.
180
+ */
181
+ export interface SidebarSettingsFace {
182
+ /** The current resolved value + revision (undefined while the settings service is absent). */
183
+ get(): { value?: unknown; revision?: number }
184
+ /** Merge a patch (revision-guarded) and return the fresh resolved view. */
185
+ update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
186
+ }
187
+
188
+ /** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
189
+ function buildApi(
190
+ ctx: Context,
191
+ ptyManager: PtyManager,
192
+ agentPtyRegistry: AgentPtyRegistry,
193
+ resolved: ResolvedSidebarConfig,
194
+ getSettings: () => SidebarSettingsFace | undefined,
195
+ ): Record<string, ApiMethod> {
196
+ const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
197
+ const sessionId = requireString(payload, 'sessionId')
198
+ const record = payload as { cwd?: unknown } | null
199
+ const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
200
+ return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
201
+ }
202
+ // Background jobs: the LIST rides the harness's `session/jobs` push
203
+ // mirror, so these routes only replay output the model has read (from the
204
+ // session's own event log — no DSH source is touched, the model's
205
+ // job_output cursor is never consumed) and kill (the registry's stock
206
+ // API). A deployment without the jobs registry downgrades kill to a 503.
207
+ const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
208
+ return {
209
+ 'session.cwd': (payload) => {
210
+ const { sessionId, cwd } = cwdOf(payload)
211
+ return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
212
+ },
213
+ 'fs.tree': async (payload) => {
214
+ const { cwd } = cwdOf(payload)
215
+ const record = payload as { path?: unknown }
216
+ const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
217
+ return listDirectory(target, resolved.listLimit)
218
+ },
219
+ 'fs.read': async (payload) => {
220
+ const { cwd } = cwdOf(payload)
221
+ // Relative paths are git-derived (status/diff report repo-root-relative
222
+ // names; the untracked diff view reads the file through this route).
223
+ const path = await resolveGitPath(cwd, requireString(payload, 'path'))
224
+ const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
225
+ if (binary) return { kind: 'binary', size, truncated, head }
226
+ return { kind: 'text', content, truncated }
227
+ },
228
+ 'fs.write': async (payload) => {
229
+ const { cwd } = cwdOf(payload)
230
+ const path = requireAbsolute(requireString(payload, 'path'))
231
+ const content = requireString(payload, 'content')
232
+ const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
233
+ try {
234
+ await mkdir(dirname(path), { recursive: true })
235
+ await writeFile(tmp, content, 'utf8')
236
+ await rename(tmp, path)
237
+ } catch (error) {
238
+ await rm(tmp, { force: true }).catch(() => {})
239
+ throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
240
+ }
241
+ return { ok: true }
242
+ },
243
+ 'git.status': async (payload) => {
244
+ const { cwd } = cwdOf(payload)
245
+ return git.status(cwd)
246
+ },
247
+ 'git.diff': async (payload) => {
248
+ const { cwd } = cwdOf(payload)
249
+ const record = payload as { path?: unknown; staged?: unknown }
250
+ const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
251
+ return { diff: await git.diff(cwd, path, record.staged === true) }
252
+ },
253
+ 'git.stage': async (payload) => {
254
+ const { cwd } = cwdOf(payload)
255
+ const record = payload as { path?: unknown }
256
+ const path = record.path === undefined ? undefined : requireString(payload, 'path')
257
+ await git.stage(cwd, path)
258
+ return { ok: true }
259
+ },
260
+ 'git.unstage': async (payload) => {
261
+ const { cwd } = cwdOf(payload)
262
+ const record = payload as { path?: unknown }
263
+ const path = record.path === undefined ? undefined : requireString(payload, 'path')
264
+ await git.unstage(cwd, path)
265
+ return { ok: true }
266
+ },
267
+ 'git.commit': async (payload) => {
268
+ const { cwd } = cwdOf(payload)
269
+ const message = requireString(payload, 'message')
270
+ await git.commit(cwd, message)
271
+ return { ok: true }
272
+ },
273
+ 'git.branch': async (payload) => {
274
+ const { cwd } = cwdOf(payload)
275
+ return git.branches(cwd)
276
+ },
277
+ 'git.checkout': async (payload) => {
278
+ const { cwd } = cwdOf(payload)
279
+ await git.checkout(cwd, requireString(payload, 'branch'))
280
+ return { ok: true }
281
+ },
282
+ 'git.log': async (payload) => {
283
+ const { cwd } = cwdOf(payload)
284
+ const record = payload as { count?: unknown; skip?: unknown }
285
+ const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
286
+ ? record.count
287
+ : undefined
288
+ const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
289
+ ? record.skip
290
+ : undefined
291
+ return git.log(cwd, count, skip)
292
+ },
293
+ 'git.commit-diff': async (payload) => {
294
+ const { cwd } = cwdOf(payload)
295
+ return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
296
+ },
297
+ 'git.discard': async (payload) => {
298
+ const { cwd } = cwdOf(payload)
299
+ await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
300
+ return { ok: true }
301
+ },
302
+ 'git.revert': async (payload) => {
303
+ const { cwd } = cwdOf(payload)
304
+ await git.revert(cwd, requireString(payload, 'hash'))
305
+ return { ok: true }
306
+ },
307
+ 'git.cherry-pick': async (payload) => {
308
+ const { cwd } = cwdOf(payload)
309
+ await git.cherryPick(cwd, requireString(payload, 'hash'))
310
+ return { ok: true }
311
+ },
312
+ 'git.show': async (payload) => {
313
+ const { cwd } = cwdOf(payload)
314
+ const path = await resolveGitPath(cwd, requireString(payload, 'path'))
315
+ const rev = requireString(payload, 'rev')
316
+ return { content: await git.show(cwd, rev, path) }
317
+ },
318
+ // Release a terminal immediately. The WebSocket close frame already does
319
+ // this while the socket is open; this route covers the tab-close that
320
+ // happens while the socket is down (reconnect loop), so a closed tab can
321
+ // never hold the per-session quota until the reconnect grace expires.
322
+ 'pty.close': (payload) => {
323
+ const sessionId = requireString(payload, 'sessionId')
324
+ const tab = requireString(payload, 'tab')
325
+ ptyManager.close(`${sessionId}:${tab}`)
326
+ return { ok: true }
327
+ },
328
+ // Release an agent terminal by uuid. The WS close frame already does
329
+ // this while the socket is open; this route covers the tab-close that
330
+ // happens while the socket is down (reconnect loop) so a closed agent
331
+ // tab never leaves a zombie pty behind. Idempotent.
332
+ 'agent-pty.close': (payload) => {
333
+ const uuid = requireString(payload, 'uuid')
334
+ agentPtyRegistry.close(uuid)
335
+ return { ok: true }
336
+ },
337
+ // Background jobs: read one job's output (a REPLAY of what the model
338
+ // has read so far, from the owner session's event log — the model's
339
+ // job_output cursor is never touched, so the human pane can never steal
340
+ // the agent's bytes), and kill one job. The job LIST itself arrives
341
+ // through the harness's session/jobs push mirror, so no list route
342
+ // exists. Kill is fenced to the owning session by the jobs registry.
343
+ 'jobs.output': (payload) => jobsApi.output(payload),
344
+ 'jobs.kill': (payload) => jobsApi.kill(payload),
345
+ // The side card preferences. The settings service is optional in the
346
+ // composition; while absent the routes report undefined and the client
347
+ // keeps the schema defaults. Writes are revision-guarded: a stale editor
348
+ // is refused with settings-conflict so a concurrent change is never
349
+ // silently overwritten (mirror of the settings seam's own guard).
350
+ 'settings.get': () => {
351
+ const settings = getSettings()
352
+ return settings?.get() ?? { value: undefined, revision: undefined }
353
+ },
354
+ 'settings.update': async (payload) => {
355
+ const settings = getSettings()
356
+ if (settings === undefined) {
357
+ throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
358
+ }
359
+ const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
360
+ const patch = record?.patch
361
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
362
+ throw new SidebarError('bad-request', 'patch must be a plain object')
363
+ }
364
+ const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
365
+ try {
366
+ return await settings.update(patch as Record<string, unknown>, expectedRevision)
367
+ } catch (error) {
368
+ if (error instanceof SettingsConflictError) {
369
+ throw new SidebarError('settings-conflict', error.message, 409)
370
+ }
371
+ throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
372
+ }
373
+ },
374
+ // Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
375
+ // iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
376
+ // signals the browser enforces when it refuses to embed a site. The
377
+ // probe is display-only (headers back to the caller), restricted to
378
+ // http(s) non-loopback URLs with a hard timeout, and gated by the same
379
+ // trust fence as every other route — a cross-site page cannot reach it.
380
+ 'browser.probe': async (payload) => {
381
+ const raw = requireString(payload, 'url')
382
+ let parsed: URL
383
+ try {
384
+ parsed = new URL(raw)
385
+ } catch {
386
+ throw new SidebarError('bad-request', 'invalid url', 400)
387
+ }
388
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
389
+ throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
390
+ }
391
+ // Mirror the browser tab's address-bar policy: loopback stays unreachable
392
+ // from the sidebar, so probing it would leak nothing the tab could use.
393
+ if (isLoopbackHostname(parsed.hostname)) {
394
+ throw new SidebarError('bad-request', 'local addresses are not probed', 400)
395
+ }
396
+ const controller = new AbortController()
397
+ const timer = setTimeout(() => controller.abort(), 8000)
398
+ try {
399
+ let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
400
+ // Some servers answer HEAD with 405/501; retry once as GET (the
401
+ // body is discarded — only the headers matter).
402
+ if (response.status === 405 || response.status === 501) {
403
+ response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
404
+ }
405
+ const csp = response.headers.get('content-security-policy')
406
+ const frameAncestors = extractFrameAncestors(csp)
407
+ const xFrameOptions = response.headers.get('x-frame-options')
408
+ return {
409
+ reachable: true,
410
+ url: response.url,
411
+ status: response.status,
412
+ ...(xFrameOptions !== null ? { xFrameOptions } : {}),
413
+ ...(frameAncestors !== undefined ? { frameAncestors } : {}),
414
+ }
415
+ } catch {
416
+ // DNS / TLS / connection / timeout: nothing to judge — the client
417
+ // keeps the plain iframe.
418
+ return { reachable: false }
419
+ } finally {
420
+ clearTimeout(timer)
421
+ }
422
+ },
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Plugin body: mount the fenced routes and the pty lifecycle.
428
+ * @param ctx - host plugin context (webServer, sessions, loader).
429
+ * @param config - deployment-provided limits; the Loader validates against
430
+ * {@link Config} and fills defaults, direct callers get them from
431
+ * {@link resolveSidebarConfig}.
432
+ */
433
+ export function apply(ctx: Context, config?: SidebarConfig): void {
434
+ // pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
435
+ // restore it before any terminal can spawn (idempotent).
436
+ ensureSpawnHelper()
437
+ const resolved = resolveSidebarConfig(config)
438
+ const trustedHosts = trustedHostsOf(ctx)
439
+ const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
440
+ const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
441
+ // The agent-owned terminal registry: parallel to the UI-tab ptyManager,
442
+ // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
443
+ // uncapped, and torn down with the plugin. The model creates terminals here
444
+ // through the terminal_create tool; the sidebar view attaches through the
445
+ // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
446
+ const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
447
+
448
+ // ── User-facing "Side card" preferences ──────────────────────────────────
449
+ // Register the namespace with the settings provider so the Settings page
450
+ // (client half) can render and persist the new-conversation defaults. The
451
+ // DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
452
+ // configuration clients, so the client reaches this namespace through the
453
+ // plugin's own fenced routes below ('settings.get'/'settings.update'),
454
+ // which call the seam in-process. Deployments without a settings service
455
+ // simply never fill the face and the client falls back to the defaults.
456
+ let settingsFace: SidebarSettingsFace | undefined
457
+ // The model-facing terminal tools are gated on the side-card setting
458
+ // `agentTerminalTools` (default off): nothing is injected until the user
459
+ // turns the feature on, and turning it off mid-session unregisters the
460
+ // tools and releases the agent terminals they created.
461
+ let toolsDisposers: (() => void) | null = null
462
+ const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
463
+ if (scope.get().agentTerminalTools) {
464
+ if (toolsDisposers === null) {
465
+ toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
466
+ }
467
+ } else if (toolsDisposers !== null) {
468
+ toolsDisposers()
469
+ toolsDisposers = null
470
+ // The feature is off: release every agent terminal the model created
471
+ // while it was on (they are only reachable through the tools). The
472
+ // registry change fires the push, so the sidebar reconciles them away.
473
+ agentPtyRegistry.disposeAll()
474
+ }
475
+ }
476
+ ctx.inject(['settings'], (sctx) => {
477
+ const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
478
+ // The structural settings mirror types `schema` as unknown, so the
479
+ // generic is not inferred here; the real service resolves it from the
480
+ // schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
481
+ const scope = sctx.settings.register(ns, PrefsSchema) as {
482
+ get(): SidebarPrefs
483
+ watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
484
+ }
485
+ const viewOf = (): { value?: unknown; revision?: number } => {
486
+ const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
487
+ return descriptor === undefined
488
+ ? { value: undefined, revision: undefined }
489
+ : { value: descriptor.value, revision: descriptor.revision }
490
+ }
491
+ settingsFace = {
492
+ get: viewOf,
493
+ update: async (patch, expectedRevision) => {
494
+ await sctx.settings.update(ns, patch, expectedRevision)
495
+ return viewOf()
496
+ },
497
+ }
498
+ // Register (or unregister) the terminal tools from the current setting,
499
+ // and keep them in sync with every settings commit.
500
+ syncToolsGate(scope)
501
+ scope.watch(() => { syncToolsGate(scope) })
502
+ })
503
+
504
+ // ── JSON API ────────────────────────────────────────────────────────────
505
+ const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
506
+ ctx.effect(() => ctx.webServer.register({
507
+ kind: 'prefix',
508
+ path: '/sidebar/api',
509
+ handler: async (req, res) => {
510
+ if (!fence(req)) {
511
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
512
+ return
513
+ }
514
+ if (req.method !== 'POST') {
515
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
516
+ return
517
+ }
518
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
519
+ const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
520
+ if (method === undefined || method.includes('/')) {
521
+ writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
522
+ return
523
+ }
524
+ try {
525
+ const payload = await readJsonBody(req)
526
+ const handler = api[method]
527
+ if (handler === undefined) {
528
+ throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
529
+ }
530
+ writeOk(res, await handler(payload))
531
+ } catch (error) {
532
+ writeError(res, error)
533
+ }
534
+ },
535
+ }), 'dsh-better-sidebar: /sidebar/api routes')
536
+
537
+ // ── Lazy chunk route (client bundle splits) ─────────────────────────────
538
+ // Serves the client half's split bundles (lib/client-<name>.js) so the
539
+ // heavy preview/terminal libraries load on first use, not at page start
540
+ // (see bundle-route.ts / src/client/chunk-loader.ts).
541
+ ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
542
+
543
+ // ── Media route (images for the editor) ─────────────────────────────────
544
+ ctx.effect(() => ctx.webServer.register({
545
+ kind: 'prefix',
546
+ path: '/sidebar/file',
547
+ handler: async (req, res) => {
548
+ if (!fence(req)) {
549
+ res.writeHead(403)
550
+ res.end('forbidden')
551
+ return
552
+ }
553
+ if (req.method !== 'GET') {
554
+ res.writeHead(405)
555
+ res.end()
556
+ return
557
+ }
558
+ try {
559
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
560
+ const sessionId = url.searchParams.get('sessionId')
561
+ const raw = url.searchParams.get('path')
562
+ if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
563
+ const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
564
+ const path = requireAbsolute(raw)
565
+ if (!isWithin(cwd, path)) {
566
+ // Only files under the session cwd are served as media (the editor
567
+ // opens images from the explorer; produced files go through read).
568
+ // isWithin (not a raw startsWith) so case-mismatched Windows paths
569
+ // and mixed separators cannot be misclassified.
570
+ throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
571
+ }
572
+ const info = await stat(path)
573
+ if (!info.isFile() || info.size > resolved.mediaLimit) {
574
+ throw new SidebarError('fs-error', 'not a file or too large', 400)
575
+ }
576
+ const type = mediaTypeForPath(path)
577
+ const body = await readFile(path)
578
+ // Raw bytes either way (binary-safe); ?download=1 switches the
579
+ // disposition so the browser saves the file instead of showing it.
580
+ const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
581
+ if (url.searchParams.get('download') === '1') {
582
+ headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
583
+ }
584
+ res.writeHead(200, headers)
585
+ res.end(body)
586
+ } catch (error) {
587
+ writeError(res, error)
588
+ }
589
+ },
590
+ }), 'dsh-better-sidebar: /sidebar/file media route')
591
+
592
+ // ── HTML preview route (sandboxed HTML + its relative assets) ───────────
593
+ // Serves files under the session cwd for the built-in HTML previewer. The
594
+ // URL is path-encoded (see html-route.ts) so the previewed page's relative
595
+ // assets (./style.css, img/x.png) resolve back into this route with the
596
+ // session scope intact — a query-encoded URL would drop the scope when the
597
+ // browser resolves relatives. Every response carries the CSP `sandbox`
598
+ // directive: inside the editor's iframe the sandbox ATTRIBUTE is the
599
+ // boundary, this header is defense-in-depth so even a top-level load of
600
+ // the URL (e.g. a popup opened by a previewed page) stays in an opaque
601
+ // origin with no same-origin access to the GUI.
602
+ ctx.effect(() => ctx.webServer.register({
603
+ kind: 'prefix',
604
+ path: '/sidebar/html',
605
+ handler: async (req, res) => {
606
+ if (!fence(req)) {
607
+ res.writeHead(403)
608
+ res.end('forbidden')
609
+ return
610
+ }
611
+ if (req.method !== 'GET') {
612
+ res.writeHead(405)
613
+ res.end()
614
+ return
615
+ }
616
+ try {
617
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
618
+ const decoded = decodeHtmlUrl(url.pathname)
619
+ if (!decoded.ok) {
620
+ writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
621
+ return
622
+ }
623
+ const { sessionId, path } = decoded.ref
624
+ // The session's authoritative cwd (client cwd cannot ride in the URL
625
+ // — the path encoding has no query; a detached first request falls
626
+ // back to the process cwd and is normally refused by isWithin, same
627
+ // semantics as the media route's fallback).
628
+ const cwd = sessionCwdOf(ctx, sessionId)
629
+ const absolute = requireAbsolute(path)
630
+ if (!isWithin(cwd, absolute)) {
631
+ throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
632
+ }
633
+ const info = await stat(absolute)
634
+ if (!info.isFile() || info.size > resolved.mediaLimit) {
635
+ throw new SidebarError('fs-error', 'not a file or too large', 400)
636
+ }
637
+ const type = mediaTypeForPath(absolute)
638
+ const body = await readFile(absolute)
639
+ res.writeHead(200, {
640
+ 'content-type': type,
641
+ 'cache-control': 'no-cache',
642
+ 'x-content-type-options': 'nosniff',
643
+ 'referrer-policy': 'no-referrer',
644
+ // The sandbox directive (no allow-same-origin → opaque origin) is
645
+ // the previewer's security boundary even for top-level loads;
646
+ // object-src 'none' blocks plugin embeds.
647
+ 'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
648
+ })
649
+ res.end(body)
650
+ } catch (error) {
651
+ writeError(res, error)
652
+ }
653
+ },
654
+ }), 'dsh-better-sidebar: /sidebar/html preview route')
655
+
656
+ // ── Terminal WebSocket ──────────────────────────────────────────────────
657
+ // One upgrade endpoint serves both UI-tab terminals (?tab=...) and
658
+ // agent-owned terminals (?uuid=...). The two paths attach to different
659
+ // registries but share the wire protocol: input frames are raw text,
660
+ // resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
661
+ // `{type:'close'}` releases the underlying pty (immediate for agent
662
+ // terminals, scheduled-0 for UI tabs which keep the same reconnect grace
663
+ // contract the host has always had).
664
+ const wss = new WebSocketServer({ noServer: true })
665
+ ctx.effect(() => ctx.webServer.registerUpgrade({
666
+ path: '/sidebar/ws/terminal',
667
+ handler: (req, socket, head) => {
668
+ if (!fence(req)) {
669
+ socket.destroy()
670
+ return
671
+ }
672
+ wss.handleUpgrade(req, socket, head, (ws) => {
673
+ void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
674
+ })
675
+ },
676
+ }), 'dsh-better-sidebar: terminal WebSocket')
677
+
678
+ // ── Agent terminals push WebSocket ──────────────────────────────────────
679
+ // Pushes the live list of agent terminals for one session to the sidebar
680
+ // view: the client mirrors the list into tabs (id `agent:<uuid>`,
681
+ // title from the agent's `terminal_create` call). The host fires on every
682
+ // create / close / exit; the client reconciles by adding tabs for new
683
+ // uuids and dropping tabs whose uuids disappeared (the user closing a tab
684
+ // sends `{type:'close'}` on the terminal WS, which kills the pty, which
685
+ // fires a change here, which converges the view).
686
+ const agentListWss = new WebSocketServer({ noServer: true })
687
+ ctx.effect(() => ctx.webServer.registerUpgrade({
688
+ path: '/sidebar/ws/agent-terminals',
689
+ handler: (req, socket, head) => {
690
+ if (!fence(req)) {
691
+ socket.destroy()
692
+ return
693
+ }
694
+ agentListWss.handleUpgrade(req, socket, head, (ws) => {
695
+ void attachAgentList(agentPtyRegistry, ws, req)
696
+ })
697
+ },
698
+ }), 'dsh-better-sidebar: agent-terminals push WebSocket')
699
+
700
+ ctx.effect(() => () => {
701
+ toolsDisposers?.()
702
+ ptyManager.disposeAll()
703
+ agentPtyRegistry.disposeAll()
704
+ wss.close()
705
+ agentListWss.close()
706
+ }, 'dsh-better-sidebar: teardown')
707
+ }
708
+
709
+ /** Push the live agent-terminal list for one session to a connected sidebar view. */
710
+ async function attachAgentList(
711
+ registry: AgentPtyRegistry,
712
+ ws: WebSocket,
713
+ req: IncomingMessage,
714
+ ): Promise<void> {
715
+ try {
716
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
717
+ const sessionId = url.searchParams.get('sessionId')
718
+ if (sessionId === null) {
719
+ ws.close(1008, 'sessionId is required')
720
+ return
721
+ }
722
+ const send = (): void => {
723
+ if (ws.readyState === WebSocket.OPEN) {
724
+ ws.send(JSON.stringify(registry.list(sessionId)))
725
+ }
726
+ }
727
+ send()
728
+ const unsubscribe = registry.subscribe(send)
729
+ ws.on('close', () => { unsubscribe() })
730
+ ws.on('error', () => { unsubscribe() })
731
+ } catch (error) {
732
+ ws.close(1011, error instanceof Error ? error.message : String(error))
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Wire one terminal socket to its pty: replay transcript, pump both ways.
738
+ * Two attach modes share the wire protocol:
739
+ * - `?uuid=...` attaches to an agent-owned terminal (created by the
740
+ * `terminal_create` tool). The close frame kills the pty immediately
741
+ * (the agent's terminal closes when the user closes the sidebar tab); a
742
+ * bare socket drop (refresh, tab switch) leaves the pty alive for the
743
+ * reconnect grace, exactly like UI-tab terminals.
744
+ * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
745
+ * created it from the + menu). The close frame schedules a 0-ms close
746
+ * (the host's reconnect grace keeps the shell alive across a refresh).
747
+ */
748
+ async function attachTerminal(
749
+ ctx: Context,
750
+ ptyManager: PtyManager,
751
+ agentPtyRegistry: AgentPtyRegistry,
752
+ ws: WebSocket,
753
+ req: IncomingMessage,
754
+ resolved: ResolvedSidebarConfig,
755
+ ): Promise<void> {
756
+ try {
757
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
758
+ const uuid = url.searchParams.get('uuid')
759
+ if (uuid !== null) {
760
+ const handle = agentPtyRegistry.get(uuid)
761
+ if (handle === undefined) {
762
+ ws.close(1011, `agent terminal "${uuid}" not found`)
763
+ return
764
+ }
765
+ pumpAgentTerminal(agentPtyRegistry, handle, ws)
766
+ return
767
+ }
768
+ const sessionId = url.searchParams.get('sessionId')
769
+ const tabId = url.searchParams.get('tab')
770
+ if (sessionId === null || tabId === null) {
771
+ ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
772
+ return
773
+ }
774
+ const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
775
+ const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
776
+ // Replay the transcript, then follow live output.
777
+ if (handle.transcript !== '') ws.send(handle.transcript)
778
+ const onData = (data: string): void => {
779
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
780
+ ws.send(data)
781
+ }
782
+ }
783
+ const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
784
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
785
+ }
786
+ const dataSub = handle.pty.onData(onData)
787
+ const exitSub = handle.pty.onExit(onExit)
788
+ ws.on('message', (data) => {
789
+ const text = data.toString('utf8')
790
+ // Control frames are JSON with a known shape; anything else (including
791
+ // JSON that is not a recognized control) is terminal input, verbatim.
792
+ let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
793
+ try {
794
+ const parsed: unknown = JSON.parse(text)
795
+ if (parsed !== null && typeof parsed === 'object') {
796
+ control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
797
+ }
798
+ } catch {
799
+ // Not JSON: terminal input.
800
+ }
801
+ if (control !== null && control.type === 'close') {
802
+ // The owning tab was closed: release the quota immediately.
803
+ ptyManager.scheduleClose(handle.key, 0)
804
+ return
805
+ }
806
+ if (handle.exited) return
807
+ if (
808
+ control !== null
809
+ && control.type === 'resize'
810
+ && typeof control.cols === 'number' && typeof control.rows === 'number'
811
+ ) {
812
+ const dims = clampDims(control.cols, control.rows)
813
+ handle.pty.resize(dims.cols, dims.rows)
814
+ } else {
815
+ handle.pty.write(text)
816
+ }
817
+ })
818
+ ws.on('close', () => {
819
+ dataSub.dispose()
820
+ exitSub.dispose()
821
+ // A bare socket drop (refresh, tab switch) leaves the process alive
822
+ // for a grace period so a quick reconnect keeps it; the reconnect's
823
+ // open() cancels the pending close.
824
+ ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
825
+ })
826
+ } catch (error) {
827
+ ws.close(1011, error instanceof Error ? error.message : String(error))
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Pump one agent terminal's pty to a connected view. The close frame kills
833
+ * the pty immediately (the agent's terminal closes when the user closes the
834
+ * sidebar tab); a bare socket drop leaves the pty alive — the agent owns
835
+ * the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
836
+ * plugin teardown kills it.
837
+ */
838
+ function pumpAgentTerminal(
839
+ registry: AgentPtyRegistry,
840
+ handle: AgentTerminalHandle,
841
+ ws: WebSocket,
842
+ ): void {
843
+ if (handle.transcript !== '') ws.send(handle.transcript)
844
+ const onData = (data: string): void => {
845
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
846
+ ws.send(data)
847
+ }
848
+ }
849
+ const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
850
+ onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
851
+ }
852
+ const dataSub = handle.pty.onData(onData)
853
+ const exitSub = handle.pty.onExit(onExit)
854
+ ws.on('message', (data) => {
855
+ if (handle.exited) return
856
+ const text = data.toString('utf8')
857
+ let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
858
+ try {
859
+ const parsed: unknown = JSON.parse(text)
860
+ if (parsed !== null && typeof parsed === 'object') {
861
+ control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
862
+ }
863
+ } catch {
864
+ // Not JSON: terminal input.
865
+ }
866
+ if (control !== null && control.type === 'close') {
867
+ // The user closed the sidebar tab: kill the pty immediately. The
868
+ // agent's next terminal_list / terminal_send will see it gone.
869
+ registry.close(handle.uuid)
870
+ return
871
+ }
872
+ if (
873
+ control !== null
874
+ && control.type === 'resize'
875
+ && typeof control.cols === 'number' && typeof control.rows === 'number'
876
+ ) {
877
+ const dims = clampDims(control.cols, control.rows)
878
+ handle.pty.resize(dims.cols, dims.rows)
879
+ } else if (control === null) {
880
+ // Raw text input (a JSON-looking string the pty would have received
881
+ // verbatim is reachable in theory but is exotic for an agent terminal;
882
+ // preserve the UI-tab semantics and forward as input).
883
+ handle.pty.write(text)
884
+ }
885
+ // An unrecognized JSON control frame is dropped (the UI-tab path also
886
+ // treats non-resize JSON controls as input, but for an agent terminal
887
+ // there is no realistic input that is also valid JSON).
888
+ })
889
+ ws.on('close', () => {
890
+ dataSub.dispose()
891
+ exitSub.dispose()
892
+ // A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
893
+ // The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
894
+ // frame, or plugin teardown kills it. A reconnecting view reattaches the
895
+ // same shell and gets the full transcript replayed.
896
+ })
897
+ }