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/package.json ADDED
@@ -0,0 +1,168 @@
1
+ {
2
+ "name": "dsh-better-sidebar",
3
+ "version": "0.10.0",
4
+ "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dsh-external/DSH-better-sidebar"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "main": "lib/index.js",
14
+ "types": "lib/types/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./lib/types/index.d.ts",
18
+ "default": "./lib/index.js"
19
+ },
20
+ "./invariant": {
21
+ "types": "./lib/types/invariant.d.ts",
22
+ "default": "./lib/invariant.js"
23
+ },
24
+ "./client": {
25
+ "types": "./lib/types/client/index.d.ts",
26
+ "default": "./lib/client.js"
27
+ },
28
+ "./client/service": {
29
+ "types": "./lib/types/client/service.d.ts",
30
+ "default": "./lib/client.js"
31
+ },
32
+ "./client/api": {
33
+ "types": "./lib/types/client/service.d.ts",
34
+ "default": "./lib/client.js"
35
+ },
36
+ "./src/*": "./src/*",
37
+ "./package.json": "./package.json"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "dsh": {
43
+ "client": {
44
+ "inject": [
45
+ "@deepseek-ai/dsh-client-runtime",
46
+ "@deepseek-ai/dsh-client-locale",
47
+ "@deepseek-ai/dsh-client-ui-slots",
48
+ "@deepseek-ai/dsh-client-ui-conversation"
49
+ ],
50
+ "platform": "web"
51
+ }
52
+ },
53
+ "files": [
54
+ "lib/index.js",
55
+ "lib/invariant.js",
56
+ "lib/client.js",
57
+ "lib/client-registry.js",
58
+ "lib/client-docx.js",
59
+ "lib/client-xlsx.js",
60
+ "lib/client-pptx.js",
61
+ "lib/client-terminal.js",
62
+ "lib/client-editor.js",
63
+ "lib/types/**/*.d.ts",
64
+ "src",
65
+ "README.md",
66
+ "README_EN.md",
67
+ "LICENSE"
68
+ ],
69
+ "license": "MIT",
70
+ "peerDependencies": {
71
+ "@deepseek-ai/cordis": "^4.0.1",
72
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
75
+ "@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
76
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
77
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
78
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
79
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
80
+ "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
81
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
82
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
83
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
84
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
85
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
86
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
87
+ "cordis": "^4.0.0-rc.7",
88
+ "react": "^18.2.0",
89
+ "react-dom": "^18.2.0"
90
+ },
91
+ "peerDependenciesMeta": {
92
+ "cordis": {
93
+ "optional": true
94
+ }
95
+ },
96
+ "dependencies": {
97
+ "@aiden0z/pptx-renderer": "^1.2.4",
98
+ "@codemirror/commands": "^6.10.4",
99
+ "@codemirror/lang-cpp": "^6.0.3",
100
+ "@codemirror/lang-css": "^6.3.1",
101
+ "@codemirror/lang-go": "^6.0.1",
102
+ "@codemirror/lang-html": "^6.4.12",
103
+ "@codemirror/lang-java": "^6.0.2",
104
+ "@codemirror/lang-javascript": "^6.2.5",
105
+ "@codemirror/lang-json": "^6.0.2",
106
+ "@codemirror/lang-markdown": "^6.5.2",
107
+ "@codemirror/lang-php": "^6.0.2",
108
+ "@codemirror/lang-python": "^6.2.1",
109
+ "@codemirror/lang-rust": "^6.0.2",
110
+ "@codemirror/lang-sql": "^6.10.0",
111
+ "@codemirror/lang-xml": "^6.1.0",
112
+ "@codemirror/lang-yaml": "^6.1.3",
113
+ "@codemirror/language": "^6.12.4",
114
+ "@codemirror/legacy-modes": "^6.5.3",
115
+ "@codemirror/search": "^6.7.1",
116
+ "@codemirror/state": "^6.7.1",
117
+ "@codemirror/view": "^6.43.8",
118
+ "@lezer/highlight": "^1.2.3",
119
+ "@univerjs/preset-sheets-core": "^0.25.1",
120
+ "@univerjs/presets": "^0.25.1",
121
+ "@xterm/addon-fit": "^0.10.0",
122
+ "clsx": "^2.1.1",
123
+ "docx-preview": "^0.4.0",
124
+ "node-pty": "^1.1.0",
125
+ "rxjs": "^7.8.2",
126
+ "schemastery": "^3.18.0",
127
+ "ws": "^8.18.0",
128
+ "xlsx": "^0.18.5",
129
+ "xterm": "^5.3.0"
130
+ },
131
+ "devDependencies": {
132
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
133
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
134
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
135
+ "@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
136
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
137
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
138
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
139
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
140
+ "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
141
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
142
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
143
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
144
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
145
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
146
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
147
+ "@types/node": "^24.0.0",
148
+ "@types/react": "~18.3.1",
149
+ "@types/react-dom": "~18.3.1",
150
+ "@types/ws": "^8.5.10",
151
+ "@cordisjs/plugin-loader": "^1.0.0-rc.5",
152
+ "cordis": "^4.0.0-rc.7",
153
+ "jsdom": "^29.1.1",
154
+ "lightningcss": "^1.32.0",
155
+ "react": "^18.2.0",
156
+ "react-dom": "18.2.0",
157
+ "tsdown": "^0.22.2",
158
+ "typescript": "^5.6.0",
159
+ "vitest": "^4.1.8"
160
+ },
161
+ "scripts": {
162
+ "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
163
+ "typecheck": "tsc --noEmit",
164
+ "bundle": "tsdown",
165
+ "watch": "tsdown --watch",
166
+ "test": "vitest run"
167
+ }
168
+ }
@@ -0,0 +1,519 @@
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 * as nodePty from 'node-pty'
16
+ import { ensureSpawnHelper } from './pty-manager.ts'
17
+ import { SidebarError } from './wire.ts'
18
+
19
+ /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
20
+ const TRANSCRIPT_LIMIT = 1 << 20
21
+
22
+ /** POSIX signals the registry forwards to a live pty. */
23
+ export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
24
+ /** Signal name accepted by `signal()`. */
25
+ export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
26
+
27
+ /** Default read page size (lines) when the caller omits `count`. */
28
+ export const DEFAULT_READ_COUNT = 500
29
+
30
+ /** Smallest pty dimension the registry accepts (mirrors the tool contract). */
31
+ export const TERMINAL_DIM_MIN = 2
32
+ /** Largest pty dimension the registry accepts (mirrors the tool contract). */
33
+ export const TERMINAL_DIM_MAX = 1024
34
+
35
+ /** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
36
+ export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
37
+ const clamp = (value: number): number =>
38
+ Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
39
+ return { cols: clamp(cols), rows: clamp(rows) }
40
+ }
41
+
42
+ /**
43
+ * Serializable snapshot of one agent terminal — the shape the model sees
44
+ * through `terminal_list` and the sidebar sees through the push endpoint.
45
+ * Carries no pty reference and no transcript (those are reached through
46
+ * dedicated read/attach paths), and no sessionId: ownership is registry
47
+ * internals, scoped by the caller (list filters by session, the push
48
+ * endpoint scopes by its query param), never part of the serialized view.
49
+ */
50
+ export interface AgentTerminalSnapshot {
51
+ /** Stable opaque handle the model passes back to other terminal_* tools. */
52
+ uuid: string
53
+ /** Display title the model chose at create time. */
54
+ title: string
55
+ /** The command the model asked to run at create time (verbatim). */
56
+ command: string
57
+ /** Whether the top-level process has exited. */
58
+ exited: boolean
59
+ /** Exit code if exited normally; absent until the process exits. */
60
+ exitCode?: number | null
61
+ /** Exit signal name if the process was killed by a signal; null otherwise. */
62
+ exitSignal?: string | null
63
+ }
64
+
65
+ /** Map a POSIX signal number to its conventional name (best-effort). */
66
+ const SIGNAL_NAMES: Record<number, string> = {
67
+ 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
68
+ 9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
69
+ 17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
70
+ }
71
+
72
+ /** Convert a raw signal number to a name (or null when absent/unknown). */
73
+ function signalNameOf(signal: number | null | undefined): string | null {
74
+ if (signal === null || signal === undefined) return null
75
+ return SIGNAL_NAMES[signal] ?? `signal ${signal}`
76
+ }
77
+
78
+ /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
79
+ function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
80
+ if (needle === '') return undefined
81
+ const idx = transcript.indexOf(needle)
82
+ if (idx === -1) return undefined
83
+ // Walk the transcript up to the match index, counting newlines to derive
84
+ // the 0-based line; the column is the offset within that line.
85
+ let line = 0
86
+ let lineStart = 0
87
+ for (let i = 0; i < idx; i += 1) {
88
+ if (transcript.charCodeAt(i) === 0x0a /* \n */) {
89
+ line += 1
90
+ lineStart = i + 1
91
+ }
92
+ }
93
+ return { line, column: idx - lineStart }
94
+ }
95
+
96
+ /** One live agent terminal. */
97
+ export interface AgentTerminalHandle {
98
+ /** Stable opaque handle. */
99
+ uuid: string
100
+ /** Owning conversation id. */
101
+ sessionId: string
102
+ /** Display title. */
103
+ title: string
104
+ /** The command written to stdin right after spawn. */
105
+ command: string
106
+ /** The working directory the process was spawned with. */
107
+ cwd: string
108
+ /** The live pty process. */
109
+ pty: nodePty.IPty
110
+ /** Output accumulated since spawn (bounded; head dropped when over the limit). */
111
+ transcript: string
112
+ /** Whether the top-level process exited (transcript stays replayable). */
113
+ exited: boolean
114
+ /** Exit code once known. */
115
+ exitCode?: number | null
116
+ /** Exit signal number once known (POSIX only; undefined on Windows). */
117
+ exitSignal?: number | null
118
+ }
119
+
120
+ /** Read result shape (mirrors the official tool-pty terminal_read contract). */
121
+ export interface AgentTerminalReadResult {
122
+ /** The slice of transcript text for the requested page. */
123
+ text: string
124
+ /** Total lines in the retained transcript (the page may be a subset). */
125
+ totalLines: number
126
+ /** 0-based index of the first line in `text` (inclusive). */
127
+ lineBegin: number
128
+ /** 0-based index of the last line in `text` (exclusive). */
129
+ lineEnd: number
130
+ }
131
+
132
+ /** Outcome of {@link AgentPtyRegistry.waitFor}. */
133
+ export type AgentTerminalWaitResult =
134
+ | {
135
+ /** The needle was found in the transcript. */
136
+ kind: 'found'
137
+ /** The matched substring. */
138
+ needle: string
139
+ /** 0-based line index (in the retained transcript) where the needle first appeared. */
140
+ line: number
141
+ /** 0-based column index within that line where the match starts. */
142
+ column: number
143
+ /** Elapsed wall-clock milliseconds from the wait start to the match. */
144
+ elapsedMs: number
145
+ }
146
+ | {
147
+ /** The needle did not appear before the timeout. */
148
+ kind: 'timeout'
149
+ /** The needle that was awaited. */
150
+ needle: string
151
+ /** The configured timeout in milliseconds. */
152
+ timeoutMs: number
153
+ /** Total lines retained when the timeout fired (call terminal_read to inspect). */
154
+ totalLines: number
155
+ }
156
+ | {
157
+ /** The terminal exited before the needle appeared. */
158
+ kind: 'exited'
159
+ /** The needle that was awaited. */
160
+ needle: string
161
+ /** The exit code, if known. */
162
+ exitCode?: number | null
163
+ /** The exit signal name, if the process was killed by a signal. */
164
+ exitSignal?: string | null
165
+ }
166
+
167
+ /** Snapshot projection of a handle (drops the pty reference and transcript). */
168
+ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
169
+ const out: AgentTerminalSnapshot = {
170
+ uuid: handle.uuid,
171
+ title: handle.title,
172
+ command: handle.command,
173
+ exited: handle.exited,
174
+ }
175
+ if (handle.exited) {
176
+ out.exitCode = handle.exitCode ?? null
177
+ out.exitSignal = signalNameOf(handle.exitSignal)
178
+ }
179
+ return out
180
+ }
181
+
182
+ /**
183
+ * The agent terminal registry. The constructor takes the resolved shell
184
+ * binary (the same `defaultShell()` the UI-tab registry uses) and runs the
185
+ * spawn-helper chmod fix once at construction so the first agent terminal
186
+ * does not race a lazy fixer.
187
+ */
188
+ export class AgentPtyRegistry {
189
+ private readonly sessions = new Map<string, AgentTerminalHandle>()
190
+ private readonly changeListeners = new Set<() => void>()
191
+
192
+ constructor(private readonly shell: string) {
193
+ ensureSpawnHelper()
194
+ }
195
+
196
+ /**
197
+ * Spawn one agent terminal: start the shell in `cwd`, then write
198
+ * `command + '\n'` to stdin so the command runs in the fresh shell. The
199
+ * terminal stays alive after the command exits — the model can send more
200
+ * input through `terminal_send` until it calls `terminal_close` or the
201
+ * user closes the sidebar tab. An empty `command` spawns a bare shell.
202
+ * @returns the new handle's uuid (the model-facing opaque id).
203
+ */
204
+ create(
205
+ sessionId: string,
206
+ title: string,
207
+ command: string,
208
+ cwd: string,
209
+ cols = 80,
210
+ rows = 24,
211
+ ): string {
212
+ const uuid = randomUUID()
213
+ const dims = clampDims(cols, rows)
214
+ const pty = nodePty.spawn(this.shell, [], {
215
+ name: 'xterm-256color',
216
+ cols: dims.cols,
217
+ rows: dims.rows,
218
+ cwd,
219
+ env: { ...process.env },
220
+ })
221
+ const handle: AgentTerminalHandle = {
222
+ uuid,
223
+ sessionId,
224
+ title,
225
+ command,
226
+ cwd,
227
+ pty,
228
+ transcript: '',
229
+ exited: false,
230
+ }
231
+ pty.onData((data) => {
232
+ handle.transcript += data
233
+ if (handle.transcript.length > TRANSCRIPT_LIMIT) {
234
+ handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
235
+ }
236
+ })
237
+ pty.onExit(({ exitCode, signal }) => {
238
+ handle.exited = true
239
+ handle.exitCode = exitCode
240
+ handle.exitSignal = signal
241
+ this.notify()
242
+ })
243
+ if (command !== '') {
244
+ // Write the command + Enter so it runs in the freshly spawned shell.
245
+ // Use \r (carriage return) — the actual character a terminal sends for
246
+ // the Enter key — not \n (line feed). PowerShell treats a bare \n as a
247
+ // soft line break (continuation prompt ">>") rather than a command
248
+ // submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
249
+ try {
250
+ pty.write(`${command}\r`)
251
+ } catch {
252
+ // A spawn that failed between onData and onExit surfaces its own
253
+ // exit; the create call still returns the uuid so the model can
254
+ // read the transcript and see the failure.
255
+ }
256
+ }
257
+ this.sessions.set(uuid, handle)
258
+ this.notify()
259
+ return uuid
260
+ }
261
+
262
+ /** All live agent terminals belonging to one conversation. */
263
+ list(sessionId: string): AgentTerminalSnapshot[] {
264
+ const out: AgentTerminalSnapshot[] = []
265
+ for (const handle of this.sessions.values()) {
266
+ if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
267
+ }
268
+ return out
269
+ }
270
+
271
+ /** Resolve a live handle by uuid, or throw `not-found`. */
272
+ private expect(uuid: string): AgentTerminalHandle {
273
+ const handle = this.sessions.get(uuid)
274
+ if (handle === undefined) {
275
+ throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
276
+ }
277
+ return handle
278
+ }
279
+
280
+ /**
281
+ * Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
282
+ * The model-facing tools call this before every uuid-keyed operation: a
283
+ * uuid from another session is indistinguishable from an unknown one, so a
284
+ * model can never reach (or probe) a terminal it does not own.
285
+ */
286
+ assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
287
+ const handle = this.expect(uuid)
288
+ if (handle.sessionId !== sessionId) {
289
+ throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
290
+ }
291
+ return handle
292
+ }
293
+
294
+ /** Resolve a handle's snapshot, or undefined if it does not exist. */
295
+ snapshot(uuid: string): AgentTerminalSnapshot | undefined {
296
+ const handle = this.sessions.get(uuid)
297
+ return handle === undefined ? undefined : snapshotOf(handle)
298
+ }
299
+
300
+ /** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
301
+ send(uuid: string, text: string): void {
302
+ const handle = this.expect(uuid)
303
+ if (handle.exited) {
304
+ throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
305
+ }
306
+ handle.pty.write(text)
307
+ }
308
+
309
+ /**
310
+ * Read one bounded page of the retained transcript. `offset` is a 0-based
311
+ * line index from the start of the retained transcript (default 0);
312
+ * `count` caps the page size (default 500). A negative `offset` reads
313
+ * from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
314
+ * so the model can paginate.
315
+ */
316
+ read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
317
+ const handle = this.expect(uuid)
318
+ const lines = handle.transcript.split('\n')
319
+ const totalLines = lines.length
320
+ const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
321
+ let start: number
322
+ if (offset === undefined || offset === 0) {
323
+ start = 0
324
+ } else if (offset < 0) {
325
+ // Negative offset: read from the end (e.g. -50 → last 50 lines).
326
+ start = Math.max(0, totalLines + offset)
327
+ } else {
328
+ start = Math.min(offset, totalLines)
329
+ }
330
+ const end = Math.min(start + pageSize, totalLines)
331
+ const slice = lines.slice(start, end).join('\n')
332
+ return {
333
+ text: slice,
334
+ totalLines,
335
+ lineBegin: start,
336
+ lineEnd: end,
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Resize a terminal's pty, clamped to the 2..1024 sane range.
342
+ * @returns the dimensions actually applied (the caller echoes these, so the
343
+ * reported value always matches the pty).
344
+ */
345
+ resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
346
+ const handle = this.expect(uuid)
347
+ const dims = clampDims(cols, rows)
348
+ if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
349
+ return dims
350
+ }
351
+
352
+ /**
353
+ * Wait for `needle` to appear in a terminal's transcript, or for the
354
+ * terminal to exit, or for the timeout to elapse — whichever happens
355
+ * first. The wait polls the live transcript every ~50ms and short-circuits
356
+ * on `signal` abort (re-thrown as the abort reason so the tool layer
357
+ * surfaces cancellation).
358
+ *
359
+ * The match scans the FULL retained transcript on each poll, not just the
360
+ * delta since the last poll — a needle that scrolled past the most recent
361
+ * chunk but is still within the ~1 MiB bound is still a match. The
362
+ * returned line/column locate the FIRST occurrence (oldest), which is what
363
+ * a user watching the terminal would have seen first.
364
+ *
365
+ * The implementation uses polling (not pty onData subscription) because
366
+ * node-pty's onData fires before the registry's own onData listener
367
+ * updates the transcript (listener order is not guaranteed), and on
368
+ * Windows ConPTY output can arrive in bursts with batching delays that
369
+ * make event-driven wakeups unreliable. A 50ms poll is fast enough for
370
+ * interactive use and simple enough to be obviously correct.
371
+ * @param uuid - terminal to watch.
372
+ * @param needle - substring to search for (case-sensitive, verbatim).
373
+ * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
374
+ * @param signal - caller-owned cancellation; aborts the wait re-throwing.
375
+ * @returns one of `found` / `timeout` / `exited`.
376
+ */
377
+ async waitFor(
378
+ uuid: string,
379
+ needle: string,
380
+ timeoutMs = 10_000,
381
+ signal?: AbortSignal,
382
+ ): Promise<AgentTerminalWaitResult> {
383
+ if (needle === '') {
384
+ throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
385
+ }
386
+ const handle = this.expect(uuid)
387
+ const timeout = Math.max(100, Math.floor(timeoutMs))
388
+ const start = Date.now()
389
+ const deadline = start + timeout
390
+ // Fast path: already exited, or the needle is already in the transcript
391
+ // (a `terminal_send` may have produced the expected output before this
392
+ // call even started).
393
+ if (handle.exited) {
394
+ return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
395
+ }
396
+ const firstHit = locateNeedle(handle.transcript, needle)
397
+ if (firstHit !== undefined) {
398
+ return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
399
+ }
400
+ // Poll loop: check the transcript every 50ms, exit on match / exit /
401
+ // abort / timeout. The handle is read live each iteration (its transcript
402
+ // and exited fields mutate as the pty produces output).
403
+ while (true) {
404
+ if (signal?.aborted) signal.throwIfAborted()
405
+ if (handle.exited) {
406
+ return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
407
+ }
408
+ const hit = locateNeedle(handle.transcript, needle)
409
+ if (hit !== undefined) {
410
+ return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
411
+ }
412
+ if (Date.now() >= deadline) {
413
+ return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
414
+ }
415
+ await new Promise(resolve => {
416
+ const t = setTimeout(resolve, 50)
417
+ // Allow the Node process to exit even if the timer is pending.
418
+ if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
419
+ })
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Send a POSIX signal to a terminal's foreground process.
425
+ *
426
+ * Two delivery paths, by signal kind:
427
+ * - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
428
+ * writing the corresponding control character to the pty stdin. This is
429
+ * how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
430
+ * line discipline (POSIX ISIG mode) or the ConPTY input pipeline
431
+ * (Windows), which translates it into a SIGINT/SIGTSTP for the
432
+ * foreground process group. This works on every platform — calling
433
+ * `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
434
+ * but writing `\x03` is universally correct.
435
+ * - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
436
+ * which maps to the platform's process-termination path (POSIX
437
+ * `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
438
+ * control characters.
439
+ */
440
+ signal(uuid: string, signal: AgentTerminalSignal): void {
441
+ const handle = this.expect(uuid)
442
+ if (handle.exited) return
443
+ // Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
444
+ // master is the cross-platform way to deliver the signal to the
445
+ // foreground process group through the terminal's input pipeline.
446
+ if (signal === 'SIGINT' || signal === 'SIGTSTP') {
447
+ const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
448
+ try {
449
+ handle.pty.write(ctrlByte)
450
+ } catch {
451
+ // A pty that rejects writes is already tearing down; the next
452
+ // onExit will mark it exited. Not an error from the caller's view.
453
+ }
454
+ return
455
+ }
456
+ // SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
457
+ try {
458
+ handle.pty.kill(signal)
459
+ } catch {
460
+ // node-pty on Windows rejects named signals other than the default;
461
+ // fall back to the default kill (TerminateProcess on Windows,
462
+ // SIGKILL-equivalent on POSIX) so the signal still takes effect.
463
+ try {
464
+ handle.pty.kill()
465
+ } catch {
466
+ // Already exited or gone; nothing left to kill.
467
+ }
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Close a terminal and drop its state. Idempotent: a second close of the
473
+ * same uuid is a no-op. Returns true iff a live handle was actually
474
+ * dropped.
475
+ */
476
+ close(uuid: string): boolean {
477
+ const handle = this.sessions.get(uuid)
478
+ if (handle === undefined) return false
479
+ this.sessions.delete(uuid)
480
+ try {
481
+ handle.pty.kill()
482
+ } catch {
483
+ // Already exited or gone; nothing left to kill.
484
+ }
485
+ this.notify()
486
+ return true
487
+ }
488
+
489
+ /** Resolve a live handle by uuid (for the WS attach path). */
490
+ get(uuid: string): AgentTerminalHandle | undefined {
491
+ return this.sessions.get(uuid)
492
+ }
493
+
494
+ /**
495
+ * Subscribe to registry changes (create / close / exit). The sidebar push
496
+ * endpoint uses this to forward snapshots to the connected view. Returns
497
+ * the unsubscribe function.
498
+ */
499
+ subscribe(listener: () => void): () => void {
500
+ this.changeListeners.add(listener)
501
+ return () => { this.changeListeners.delete(listener) }
502
+ }
503
+
504
+ /** Close every agent terminal (plugin teardown). */
505
+ disposeAll(): void {
506
+ for (const uuid of [...this.sessions.keys()]) this.close(uuid)
507
+ }
508
+
509
+ /** Fire every change listener (callers wrap in try/catch if needed). */
510
+ private notify(): void {
511
+ for (const listener of [...this.changeListeners]) {
512
+ try {
513
+ listener()
514
+ } catch {
515
+ // A listener throwing must not break the others or the registry.
516
+ }
517
+ }
518
+ }
519
+ }