@keith9681/dsh-sidebar 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
3
+ * sessions created by the model through the `terminal_create` tool. Each
4
+ * handle survives across tool calls (and across WebSocket disconnects from
5
+ * the sidebar view) until the model calls `terminal_close` or the user
6
+ * closes the corresponding sidebar tab — tmux semantics, scoped per agent
7
+ * session.
8
+ *
9
+ * This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
10
+ * `${sessionId}:${tabId}` and capped per session, while agent terminals are
11
+ * keyed by uuid and uncapped (the model is trusted to close unused ones).
12
+ * Both registries share the same shell resolver and spawn-helper fix.
13
+ */
14
+ import { randomUUID } from 'node:crypto'
15
+ import type { IPty } from 'node-pty'
16
+ import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
17
+ import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
18
+ import { SidebarError } from './wire.ts'
19
+
20
+ /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
21
+ const TRANSCRIPT_LIMIT = 1 << 20
22
+
23
+ /** POSIX signals the registry forwards to a live pty. */
24
+ export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
25
+ /** Signal name accepted by `signal()`. */
26
+ export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
27
+
28
+ /** Default read page size (lines) when the caller omits `count`. */
29
+ export const DEFAULT_READ_COUNT = 500
30
+
31
+ /** Smallest pty dimension the registry accepts (mirrors the tool contract). */
32
+ export const TERMINAL_DIM_MIN = 2
33
+ /** Largest pty dimension the registry accepts (mirrors the tool contract). */
34
+ export const TERMINAL_DIM_MAX = 1024
35
+
36
+ /** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
37
+ export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
38
+ const clamp = (value: number): number =>
39
+ Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
40
+ return { cols: clamp(cols), rows: clamp(rows) }
41
+ }
42
+
43
+ /**
44
+ * Serializable snapshot of one agent terminal — the shape the model sees
45
+ * through `terminal_list` and the sidebar sees through the push endpoint.
46
+ * Carries no pty reference and no transcript (those are reached through
47
+ * dedicated read/attach paths), and no sessionId: ownership is registry
48
+ * internals, scoped by the caller (list filters by session, the push
49
+ * endpoint scopes by its query param), never part of the serialized view.
50
+ */
51
+ export interface AgentTerminalSnapshot {
52
+ /** Stable opaque handle the model passes back to other terminal_* tools. */
53
+ uuid: string
54
+ /** Display title the model chose at create time. */
55
+ title: string
56
+ /** The command the model asked to run at create time (verbatim). */
57
+ command: string
58
+ /** Whether the top-level process has exited. */
59
+ exited: boolean
60
+ /** Exit code if exited normally; absent until the process exits. */
61
+ exitCode?: number | null
62
+ /** Exit signal name if the process was killed by a signal; null otherwise. */
63
+ exitSignal?: string | null
64
+ }
65
+
66
+ /** Map a POSIX signal number to its conventional name (best-effort). */
67
+ const SIGNAL_NAMES: Record<number, string> = {
68
+ 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
69
+ 9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
70
+ 17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
71
+ }
72
+
73
+ /** Convert a raw signal number to a name (or null when absent/unknown). */
74
+ function signalNameOf(signal: number | null | undefined): string | null {
75
+ if (signal === null || signal === undefined) return null
76
+ return SIGNAL_NAMES[signal] ?? `signal ${signal}`
77
+ }
78
+
79
+ /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
80
+ function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
81
+ if (needle === '') return undefined
82
+ const idx = transcript.indexOf(needle)
83
+ if (idx === -1) return undefined
84
+ // Walk the transcript up to the match index, counting newlines to derive
85
+ // the 0-based line; the column is the offset within that line.
86
+ let line = 0
87
+ let lineStart = 0
88
+ for (let i = 0; i < idx; i += 1) {
89
+ if (transcript.charCodeAt(i) === 0x0a /* \n */) {
90
+ line += 1
91
+ lineStart = i + 1
92
+ }
93
+ }
94
+ return { line, column: idx - lineStart }
95
+ }
96
+
97
+ /** One live agent terminal. */
98
+ export interface AgentTerminalHandle {
99
+ /** Stable opaque handle. */
100
+ uuid: string
101
+ /** Owning conversation id. */
102
+ sessionId: string
103
+ /** Display title. */
104
+ title: string
105
+ /** The command written to stdin right after spawn. */
106
+ command: string
107
+ /** The working directory the process was spawned with. */
108
+ cwd: string
109
+ /** The live pty process. */
110
+ pty: IPty
111
+ /** Output accumulated since spawn (bounded; head dropped when over the limit). */
112
+ transcript: string
113
+ /** Whether the top-level process exited (transcript stays replayable). */
114
+ exited: boolean
115
+ /** Exit code once known. */
116
+ exitCode?: number | null
117
+ /** Exit signal number once known (POSIX only; undefined on Windows). */
118
+ exitSignal?: number | null
119
+ }
120
+
121
+ /** Read result shape (mirrors the official tool-pty terminal_read contract). */
122
+ export interface AgentTerminalReadResult {
123
+ /** The slice of transcript text for the requested page. */
124
+ text: string
125
+ /** Total lines in the retained transcript (the page may be a subset). */
126
+ totalLines: number
127
+ /** 0-based index of the first line in `text` (inclusive). */
128
+ lineBegin: number
129
+ /** 0-based index of the last line in `text` (exclusive). */
130
+ lineEnd: number
131
+ }
132
+
133
+ /** Outcome of {@link AgentPtyRegistry.waitFor}. */
134
+ export type AgentTerminalWaitResult =
135
+ | {
136
+ /** The needle was found in the transcript. */
137
+ kind: 'found'
138
+ /** The matched substring. */
139
+ needle: string
140
+ /** 0-based line index (in the retained transcript) where the needle first appeared. */
141
+ line: number
142
+ /** 0-based column index within that line where the match starts. */
143
+ column: number
144
+ /** Elapsed wall-clock milliseconds from the wait start to the match. */
145
+ elapsedMs: number
146
+ }
147
+ | {
148
+ /** The needle did not appear before the timeout. */
149
+ kind: 'timeout'
150
+ /** The needle that was awaited. */
151
+ needle: string
152
+ /** The configured timeout in milliseconds. */
153
+ timeoutMs: number
154
+ /** Total lines retained when the timeout fired (call terminal_read to inspect). */
155
+ totalLines: number
156
+ }
157
+ | {
158
+ /** The terminal exited before the needle appeared. */
159
+ kind: 'exited'
160
+ /** The needle that was awaited. */
161
+ needle: string
162
+ /** The exit code, if known. */
163
+ exitCode?: number | null
164
+ /** The exit signal name, if the process was killed by a signal. */
165
+ exitSignal?: string | null
166
+ }
167
+
168
+ /** Snapshot projection of a handle (drops the pty reference and transcript). */
169
+ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
170
+ const out: AgentTerminalSnapshot = {
171
+ uuid: handle.uuid,
172
+ title: handle.title,
173
+ command: handle.command,
174
+ exited: handle.exited,
175
+ }
176
+ if (handle.exited) {
177
+ out.exitCode = handle.exitCode ?? null
178
+ out.exitSignal = signalNameOf(handle.exitSignal)
179
+ }
180
+ return out
181
+ }
182
+
183
+ /**
184
+ * The agent terminal registry. The constructor takes the resolved shell
185
+ * binary (the same `defaultShell()` the UI-tab registry uses) and runs the
186
+ * spawn-helper chmod fix once at construction so the first agent terminal
187
+ * does not race a lazy fixer.
188
+ */
189
+ export class AgentPtyRegistry {
190
+ private readonly sessions = new Map<string, AgentTerminalHandle>()
191
+ private readonly changeListeners = new Set<() => void>()
192
+
193
+ constructor(
194
+ private readonly shell: string,
195
+ private readonly shellArgs: string[] = [],
196
+ /** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
197
+ private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
198
+ ) {
199
+ ensureSpawnHelper()
200
+ }
201
+
202
+ /**
203
+ * Spawn one agent terminal: start the shell in `cwd`, then write
204
+ * `command + '\n'` to stdin so the command runs in the fresh shell. The
205
+ * terminal stays alive after the command exits — the model can send more
206
+ * input through `terminal_send` until it calls `terminal_close` or the
207
+ * user closes the sidebar tab. An empty `command` spawns a bare shell.
208
+ * @returns the new handle's uuid (the model-facing opaque id).
209
+ */
210
+ create(
211
+ sessionId: string,
212
+ title: string,
213
+ command: string,
214
+ cwd: string,
215
+ cols = 80,
216
+ rows = 24,
217
+ shell?: string,
218
+ shellArgs?: string[],
219
+ ): string {
220
+ const uuid = randomUUID()
221
+ const dims = clampDims(cols, rows)
222
+ const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
223
+ name: 'xterm-256color',
224
+ cols: dims.cols,
225
+ rows: dims.rows,
226
+ cwd,
227
+ env: { ...process.env },
228
+ })
229
+ const handle: AgentTerminalHandle = {
230
+ uuid,
231
+ sessionId,
232
+ title,
233
+ command,
234
+ cwd,
235
+ pty,
236
+ transcript: '',
237
+ exited: false,
238
+ }
239
+ pty.onData((data) => {
240
+ handle.transcript += data
241
+ if (handle.transcript.length > TRANSCRIPT_LIMIT) {
242
+ handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
243
+ }
244
+ })
245
+ pty.onExit(({ exitCode, signal }) => {
246
+ handle.exited = true
247
+ handle.exitCode = exitCode
248
+ handle.exitSignal = signal
249
+ this.notify()
250
+ })
251
+ if (command !== '') {
252
+ // Write the command + Enter so it runs in the freshly spawned shell.
253
+ // Use \r (carriage return) — the actual character a terminal sends for
254
+ // the Enter key — not \n (line feed). PowerShell treats a bare \n as a
255
+ // soft line break (continuation prompt ">>") rather than a command
256
+ // submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
257
+ try {
258
+ pty.write(`${command}\r`)
259
+ } catch {
260
+ // A spawn that failed between onData and onExit surfaces its own
261
+ // exit; the create call still returns the uuid so the model can
262
+ // read the transcript and see the failure.
263
+ }
264
+ }
265
+ this.sessions.set(uuid, handle)
266
+ this.notify()
267
+ return uuid
268
+ }
269
+
270
+ /** All live agent terminals belonging to one conversation. */
271
+ list(sessionId: string): AgentTerminalSnapshot[] {
272
+ const out: AgentTerminalSnapshot[] = []
273
+ for (const handle of this.sessions.values()) {
274
+ if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
275
+ }
276
+ return out
277
+ }
278
+
279
+ /** Resolve a live handle by uuid, or throw `not-found`. */
280
+ private expect(uuid: string): AgentTerminalHandle {
281
+ const handle = this.sessions.get(uuid)
282
+ if (handle === undefined) {
283
+ throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
284
+ }
285
+ return handle
286
+ }
287
+
288
+ /**
289
+ * Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
290
+ * The model-facing tools call this before every uuid-keyed operation: a
291
+ * uuid from another session is indistinguishable from an unknown one, so a
292
+ * model can never reach (or probe) a terminal it does not own.
293
+ */
294
+ assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
295
+ const handle = this.expect(uuid)
296
+ if (handle.sessionId !== sessionId) {
297
+ throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
298
+ }
299
+ return handle
300
+ }
301
+
302
+ /** Resolve a handle's snapshot, or undefined if it does not exist. */
303
+ snapshot(uuid: string): AgentTerminalSnapshot | undefined {
304
+ const handle = this.sessions.get(uuid)
305
+ return handle === undefined ? undefined : snapshotOf(handle)
306
+ }
307
+
308
+ /** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
309
+ send(uuid: string, text: string): void {
310
+ const handle = this.expect(uuid)
311
+ if (handle.exited) {
312
+ throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
313
+ }
314
+ handle.pty.write(text)
315
+ }
316
+
317
+ /**
318
+ * Read one bounded page of the retained transcript. `offset` is a 0-based
319
+ * line index from the start of the retained transcript (default 0);
320
+ * `count` caps the page size (default 500). A negative `offset` reads
321
+ * from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
322
+ * so the model can paginate.
323
+ */
324
+ read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
325
+ const handle = this.expect(uuid)
326
+ const lines = handle.transcript.split('\n')
327
+ const totalLines = lines.length
328
+ const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
329
+ let start: number
330
+ if (offset === undefined || offset === 0) {
331
+ start = 0
332
+ } else if (offset < 0) {
333
+ // Negative offset: read from the end (e.g. -50 → last 50 lines).
334
+ start = Math.max(0, totalLines + offset)
335
+ } else {
336
+ start = Math.min(offset, totalLines)
337
+ }
338
+ const end = Math.min(start + pageSize, totalLines)
339
+ const slice = lines.slice(start, end).join('\n')
340
+ return {
341
+ text: slice,
342
+ totalLines,
343
+ lineBegin: start,
344
+ lineEnd: end,
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Resize a terminal's pty, clamped to the 2..1024 sane range.
350
+ * @returns the dimensions actually applied (the caller echoes these, so the
351
+ * reported value always matches the pty).
352
+ */
353
+ resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
354
+ const handle = this.expect(uuid)
355
+ const dims = clampDims(cols, rows)
356
+ if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
357
+ return dims
358
+ }
359
+
360
+ /**
361
+ * Wait for `needle` to appear in a terminal's transcript, or for the
362
+ * terminal to exit, or for the timeout to elapse — whichever happens
363
+ * first. The wait polls the live transcript every ~50ms and short-circuits
364
+ * on `signal` abort (re-thrown as the abort reason so the tool layer
365
+ * surfaces cancellation).
366
+ *
367
+ * The match scans the FULL retained transcript on each poll, not just the
368
+ * delta since the last poll — a needle that scrolled past the most recent
369
+ * chunk but is still within the ~1 MiB bound is still a match. The
370
+ * returned line/column locate the FIRST occurrence (oldest), which is what
371
+ * a user watching the terminal would have seen first.
372
+ *
373
+ * The implementation uses polling (not pty onData subscription) because
374
+ * node-pty's onData fires before the registry's own onData listener
375
+ * updates the transcript (listener order is not guaranteed), and on
376
+ * Windows ConPTY output can arrive in bursts with batching delays that
377
+ * make event-driven wakeups unreliable. A 50ms poll is fast enough for
378
+ * interactive use and simple enough to be obviously correct.
379
+ * @param uuid - terminal to watch.
380
+ * @param needle - substring to search for (case-sensitive, verbatim).
381
+ * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
382
+ * @param signal - caller-owned cancellation; aborts the wait re-throwing.
383
+ * @returns one of `found` / `timeout` / `exited`.
384
+ */
385
+ async waitFor(
386
+ uuid: string,
387
+ needle: string,
388
+ timeoutMs = 10_000,
389
+ signal?: AbortSignal,
390
+ ): Promise<AgentTerminalWaitResult> {
391
+ if (needle === '') {
392
+ throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
393
+ }
394
+ const handle = this.expect(uuid)
395
+ const timeout = Math.max(100, Math.floor(timeoutMs))
396
+ const start = Date.now()
397
+ const deadline = start + timeout
398
+ // Fast path: already exited, or the needle is already in the transcript
399
+ // (a `terminal_send` may have produced the expected output before this
400
+ // call even started).
401
+ if (handle.exited) {
402
+ return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
403
+ }
404
+ const firstHit = locateNeedle(handle.transcript, needle)
405
+ if (firstHit !== undefined) {
406
+ return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
407
+ }
408
+ // Poll loop: check the transcript every 50ms, exit on match / exit /
409
+ // abort / timeout. The handle is read live each iteration (its transcript
410
+ // and exited fields mutate as the pty produces output).
411
+ while (true) {
412
+ if (signal?.aborted) signal.throwIfAborted()
413
+ if (handle.exited) {
414
+ return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
415
+ }
416
+ const hit = locateNeedle(handle.transcript, needle)
417
+ if (hit !== undefined) {
418
+ return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
419
+ }
420
+ if (Date.now() >= deadline) {
421
+ return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
422
+ }
423
+ await new Promise(resolve => {
424
+ const t = setTimeout(resolve, 50)
425
+ // Allow the Node process to exit even if the timer is pending.
426
+ if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
427
+ })
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Send a POSIX signal to a terminal's foreground process.
433
+ *
434
+ * Two delivery paths, by signal kind:
435
+ * - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
436
+ * writing the corresponding control character to the pty stdin. This is
437
+ * how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
438
+ * line discipline (POSIX ISIG mode) or the ConPTY input pipeline
439
+ * (Windows), which translates it into a SIGINT/SIGTSTP for the
440
+ * foreground process group. This works on every platform — calling
441
+ * `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
442
+ * but writing `\x03` is universally correct.
443
+ * - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
444
+ * which maps to the platform's process-termination path (POSIX
445
+ * `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
446
+ * control characters.
447
+ */
448
+ signal(uuid: string, signal: AgentTerminalSignal): void {
449
+ const handle = this.expect(uuid)
450
+ if (handle.exited) return
451
+ // Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
452
+ // master is the cross-platform way to deliver the signal to the
453
+ // foreground process group through the terminal's input pipeline.
454
+ if (signal === 'SIGINT' || signal === 'SIGTSTP') {
455
+ const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
456
+ try {
457
+ handle.pty.write(ctrlByte)
458
+ } catch {
459
+ // A pty that rejects writes is already tearing down; the next
460
+ // onExit will mark it exited. Not an error from the caller's view.
461
+ }
462
+ return
463
+ }
464
+ // SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
465
+ try {
466
+ handle.pty.kill(signal)
467
+ } catch {
468
+ // node-pty on Windows rejects named signals other than the default;
469
+ // fall back to the default kill (TerminateProcess on Windows,
470
+ // SIGKILL-equivalent on POSIX) so the signal still takes effect.
471
+ try {
472
+ handle.pty.kill()
473
+ } catch {
474
+ // Already exited or gone; nothing left to kill.
475
+ }
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Close a terminal and drop its state. Idempotent: a second close of the
481
+ * same uuid is a no-op. Returns true iff a live handle was actually
482
+ * dropped.
483
+ */
484
+ close(uuid: string): boolean {
485
+ const handle = this.sessions.get(uuid)
486
+ if (handle === undefined) return false
487
+ this.sessions.delete(uuid)
488
+ try {
489
+ handle.pty.kill()
490
+ } catch {
491
+ // Already exited or gone; nothing left to kill.
492
+ }
493
+ this.notify()
494
+ return true
495
+ }
496
+
497
+ /** Resolve a live handle by uuid (for the WS attach path). */
498
+ get(uuid: string): AgentTerminalHandle | undefined {
499
+ return this.sessions.get(uuid)
500
+ }
501
+
502
+ /**
503
+ * Subscribe to registry changes (create / close / exit). The sidebar push
504
+ * endpoint uses this to forward snapshots to the connected view. Returns
505
+ * the unsubscribe function.
506
+ */
507
+ subscribe(listener: () => void): () => void {
508
+ this.changeListeners.add(listener)
509
+ return () => { this.changeListeners.delete(listener) }
510
+ }
511
+
512
+ /** Close every agent terminal (plugin teardown). */
513
+ disposeAll(): void {
514
+ for (const uuid of [...this.sessions.keys()]) this.close(uuid)
515
+ }
516
+
517
+ /** Fire every change listener (callers wrap in try/catch if needed). */
518
+ private notify(): void {
519
+ for (const listener of [...this.changeListeners]) {
520
+ try {
521
+ listener()
522
+ } catch {
523
+ // A listener throwing must not break the others or the registry.
524
+ }
525
+ }
526
+ }
527
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure helpers for the `browser.probe` route (sidebar browser): the host
3
+ * fetches the response HEADERS of a URL the user is browsing and the client
4
+ * decides whether the target site forbids being embedded (X-Frame-Options /
5
+ * CSP frame-ancestors are exactly the signals the browser enforces when it
6
+ * refuses an iframe load). Kept dependency-free so the parser is
7
+ * unit-testable.
8
+ */
9
+
10
+ /**
11
+ * Extract the `frame-ancestors` source list of a Content-Security-Policy
12
+ * header, or undefined when the directive is absent (or empty). The
13
+ * directive is the only one with a source list; sources are space-separated
14
+ * tokens (`'none'`, `'self'`, `*`, or origins).
15
+ */
16
+ export function extractFrameAncestors(csp: string | null): string[] | undefined {
17
+ if (csp === null) return undefined
18
+ for (const directive of csp.split(';')) {
19
+ const parts = directive.trim().split(/\s+/)
20
+ if (parts[0] === 'frame-ancestors') {
21
+ const sources = parts.slice(1).filter(source => source !== '')
22
+ return sources.length === 0 ? undefined : sources
23
+ }
24
+ }
25
+ return undefined
26
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Lazy chunk route: serves the client bundle's chunk scripts
3
+ * (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
4
+ * cannot serve arbitrary file names, so the plugin serves its own split
5
+ * bundles (lib/client-<name>.js) here; the client injects the script on
6
+ * first use of the feature that needs it (see src/client/chunk-loader.ts).
7
+ *
8
+ * Caching contract: every response carries `cache-control: no-cache` plus an
9
+ * ETag (content hash, memoized per file by mtime/size) and honors
10
+ * If-None-Match — the browser revalidates each fetch, but a 304 avoids
11
+ * re-downloading multi-MB chunks that did not change (page refresh, HMR
12
+ * re-activation). Same browser-trust fence as every other /sidebar route;
13
+ * only allowlisted chunk names are servable (no path traversal).
14
+ */
15
+ import { createHash } from 'node:crypto'
16
+ import { stat, readFile } from 'node:fs/promises'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
20
+
21
+ /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
22
+ export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
23
+ export type ChunkName = (typeof CHUNK_NAMES)[number]
24
+
25
+ /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
26
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url))
27
+
28
+ /** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
29
+ function shortHash(input: string | Buffer): string {
30
+ return createHash('sha1').update(input).digest('hex').slice(0, 12)
31
+ }
32
+
33
+ interface ChunkEtag {
34
+ mtimeMs: number
35
+ size: number
36
+ etag: string
37
+ }
38
+
39
+ /** ETag memo: recompute the content hash only when the file's stat changed. */
40
+ const etags = new Map<string, ChunkEtag>()
41
+
42
+ /**
43
+ * The chunk file's ETag (quoted hash), or undefined when the file is
44
+ * missing. Hash is recomputed only when mtime/size changed (hashing a
45
+ * multi-MB chunk per request is wasteful).
46
+ */
47
+ async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
48
+ const path = join(chunkDir, `client-${name}.js`)
49
+ const key = `${chunkDir}:${name}`
50
+ try {
51
+ const info = await stat(path)
52
+ const memo = etags.get(key)
53
+ if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
54
+ return memo.etag
55
+ }
56
+ const etag = `"${shortHash(await readFile(path))}"`
57
+ etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
58
+ return etag
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Build the /sidebar/bundle route handler. `fence` is the shared browser-
66
+ * trust check every /sidebar route applies; `chunkDir` is the directory the
67
+ * chunk scripts live in (overridable for tests).
68
+ */
69
+ export function createBundleRouteHandler(
70
+ fence: (req: SidebarHttpRequest) => boolean,
71
+ chunkDir: string = LIB_DIR,
72
+ ): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
73
+ return async (req, res): Promise<void> => {
74
+ if (!fence(req)) {
75
+ res.writeHead(403)
76
+ res.end('forbidden')
77
+ return
78
+ }
79
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
80
+ res.writeHead(405)
81
+ res.end()
82
+ return
83
+ }
84
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
85
+ const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
86
+ const name = match?.[1] as ChunkName | undefined
87
+ if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
88
+ res.writeHead(404)
89
+ res.end('not found')
90
+ return
91
+ }
92
+ const etag = await etagOf(name, chunkDir)
93
+ if (etag === undefined) {
94
+ // Registered name but unreadable (bundle not built yet): loud 404.
95
+ res.writeHead(404)
96
+ res.end('not found')
97
+ return
98
+ }
99
+ if (req.headers['if-none-match'] === etag) {
100
+ // Revalidation hit: unchanged chunk, no body — avoids re-downloading
101
+ // multi-MB scripts on page refresh / HMR re-activation.
102
+ res.writeHead(304, { 'cache-control': 'no-cache', etag })
103
+ res.end()
104
+ return
105
+ }
106
+ try {
107
+ const body = await readFile(join(chunkDir, `client-${name}.js`))
108
+ res.writeHead(200, {
109
+ 'content-type': 'text/javascript; charset=utf-8',
110
+ 'cache-control': 'no-cache',
111
+ etag,
112
+ })
113
+ res.end(body)
114
+ } catch {
115
+ // Read raced a delete/rebuild between the stat and the read.
116
+ res.writeHead(404)
117
+ res.end('not found')
118
+ }
119
+ }
120
+ }
121
+
122
+ /** Register the /sidebar/bundle route (disposed with the fiber). */
123
+ export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
124
+ return ctx.webServer.register({
125
+ kind: 'prefix',
126
+ path: '/sidebar/bundle',
127
+ handler: createBundleRouteHandler(fence),
128
+ })
129
+ }