brosh 0.2.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 (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/brosh_brandmark.svg +3 -0
  4. package/brosh_logo.svg +27 -0
  5. package/cli_icon.svg +52 -0
  6. package/dist/client.d.ts +5 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +138 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +618 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/lib.d.ts +25 -0
  15. package/dist/lib.d.ts.map +1 -0
  16. package/dist/lib.js +28 -0
  17. package/dist/lib.js.map +1 -0
  18. package/dist/mode-selector.d.ts +7 -0
  19. package/dist/mode-selector.d.ts.map +1 -0
  20. package/dist/mode-selector.js +138 -0
  21. package/dist/mode-selector.js.map +1 -0
  22. package/dist/prompts/index.d.ts +3 -0
  23. package/dist/prompts/index.d.ts.map +1 -0
  24. package/dist/prompts/index.js +79 -0
  25. package/dist/prompts/index.js.map +1 -0
  26. package/dist/recording/index.d.ts +4 -0
  27. package/dist/recording/index.d.ts.map +1 -0
  28. package/dist/recording/index.js +3 -0
  29. package/dist/recording/index.js.map +1 -0
  30. package/dist/recording/manager.d.ts +62 -0
  31. package/dist/recording/manager.d.ts.map +1 -0
  32. package/dist/recording/manager.js +123 -0
  33. package/dist/recording/manager.js.map +1 -0
  34. package/dist/recording/recorder.d.ts +95 -0
  35. package/dist/recording/recorder.d.ts.map +1 -0
  36. package/dist/recording/recorder.js +330 -0
  37. package/dist/recording/recorder.js.map +1 -0
  38. package/dist/recording/types.d.ts +65 -0
  39. package/dist/recording/types.d.ts.map +1 -0
  40. package/dist/recording/types.js +2 -0
  41. package/dist/recording/types.js.map +1 -0
  42. package/dist/sandbox/ModeSelector.d.ts +2 -0
  43. package/dist/sandbox/ModeSelector.d.ts.map +1 -0
  44. package/dist/sandbox/ModeSelector.js +2 -0
  45. package/dist/sandbox/ModeSelector.js.map +1 -0
  46. package/dist/sandbox/config.d.ts +46 -0
  47. package/dist/sandbox/config.d.ts.map +1 -0
  48. package/dist/sandbox/config.js +144 -0
  49. package/dist/sandbox/config.js.map +1 -0
  50. package/dist/sandbox/controller.d.ts +72 -0
  51. package/dist/sandbox/controller.d.ts.map +1 -0
  52. package/dist/sandbox/controller.js +208 -0
  53. package/dist/sandbox/controller.js.map +1 -0
  54. package/dist/sandbox/index.d.ts +6 -0
  55. package/dist/sandbox/index.d.ts.map +1 -0
  56. package/dist/sandbox/index.js +4 -0
  57. package/dist/sandbox/index.js.map +1 -0
  58. package/dist/sandbox/mode-prompt.d.ts +10 -0
  59. package/dist/sandbox/mode-prompt.d.ts.map +1 -0
  60. package/dist/sandbox/mode-prompt.js +130 -0
  61. package/dist/sandbox/mode-prompt.js.map +1 -0
  62. package/dist/sandbox/prompt.d.ts +10 -0
  63. package/dist/sandbox/prompt.d.ts.map +1 -0
  64. package/dist/sandbox/prompt.js +434 -0
  65. package/dist/sandbox/prompt.js.map +1 -0
  66. package/dist/server.d.ts +28 -0
  67. package/dist/server.d.ts.map +1 -0
  68. package/dist/server.js +59 -0
  69. package/dist/server.js.map +1 -0
  70. package/dist/terminal/index.d.ts +5 -0
  71. package/dist/terminal/index.d.ts.map +1 -0
  72. package/dist/terminal/index.js +3 -0
  73. package/dist/terminal/index.js.map +1 -0
  74. package/dist/terminal/manager.d.ts +153 -0
  75. package/dist/terminal/manager.d.ts.map +1 -0
  76. package/dist/terminal/manager.js +276 -0
  77. package/dist/terminal/manager.js.map +1 -0
  78. package/dist/terminal/session.d.ts +137 -0
  79. package/dist/terminal/session.d.ts.map +1 -0
  80. package/dist/terminal/session.js +752 -0
  81. package/dist/terminal/session.js.map +1 -0
  82. package/dist/tools/definitions.d.ts +18 -0
  83. package/dist/tools/definitions.d.ts.map +1 -0
  84. package/dist/tools/definitions.js +114 -0
  85. package/dist/tools/definitions.js.map +1 -0
  86. package/dist/tools/getContent.d.ts +32 -0
  87. package/dist/tools/getContent.d.ts.map +1 -0
  88. package/dist/tools/getContent.js +38 -0
  89. package/dist/tools/getContent.js.map +1 -0
  90. package/dist/tools/index.d.ts +4 -0
  91. package/dist/tools/index.d.ts.map +1 -0
  92. package/dist/tools/index.js +49 -0
  93. package/dist/tools/index.js.map +1 -0
  94. package/dist/tools/screenshot.d.ts +20 -0
  95. package/dist/tools/screenshot.d.ts.map +1 -0
  96. package/dist/tools/screenshot.js +28 -0
  97. package/dist/tools/screenshot.js.map +1 -0
  98. package/dist/tools/sendKey.d.ts +31 -0
  99. package/dist/tools/sendKey.d.ts.map +1 -0
  100. package/dist/tools/sendKey.js +38 -0
  101. package/dist/tools/sendKey.js.map +1 -0
  102. package/dist/tools/startRecording.d.ts +68 -0
  103. package/dist/tools/startRecording.d.ts.map +1 -0
  104. package/dist/tools/startRecording.js +111 -0
  105. package/dist/tools/startRecording.js.map +1 -0
  106. package/dist/tools/stopRecording.d.ts +31 -0
  107. package/dist/tools/stopRecording.d.ts.map +1 -0
  108. package/dist/tools/stopRecording.js +76 -0
  109. package/dist/tools/stopRecording.js.map +1 -0
  110. package/dist/tools/type.d.ts +31 -0
  111. package/dist/tools/type.d.ts.map +1 -0
  112. package/dist/tools/type.js +31 -0
  113. package/dist/tools/type.js.map +1 -0
  114. package/dist/transport/gui-protocol.d.ts +163 -0
  115. package/dist/transport/gui-protocol.d.ts.map +1 -0
  116. package/dist/transport/gui-protocol.js +68 -0
  117. package/dist/transport/gui-protocol.js.map +1 -0
  118. package/dist/transport/gui-stream.d.ts +139 -0
  119. package/dist/transport/gui-stream.d.ts.map +1 -0
  120. package/dist/transport/gui-stream.js +440 -0
  121. package/dist/transport/gui-stream.js.map +1 -0
  122. package/dist/transport/index.d.ts +6 -0
  123. package/dist/transport/index.d.ts.map +1 -0
  124. package/dist/transport/index.js +6 -0
  125. package/dist/transport/index.js.map +1 -0
  126. package/dist/transport/socket.d.ts +46 -0
  127. package/dist/transport/socket.d.ts.map +1 -0
  128. package/dist/transport/socket.js +310 -0
  129. package/dist/transport/socket.js.map +1 -0
  130. package/dist/types/mcp-client-info.d.ts +226 -0
  131. package/dist/types/mcp-client-info.d.ts.map +1 -0
  132. package/dist/types/mcp-client-info.js +62 -0
  133. package/dist/types/mcp-client-info.js.map +1 -0
  134. package/dist/ui/index.d.ts +12 -0
  135. package/dist/ui/index.d.ts.map +1 -0
  136. package/dist/ui/index.js +84 -0
  137. package/dist/ui/index.js.map +1 -0
  138. package/dist/utils/env.d.ts +17 -0
  139. package/dist/utils/env.d.ts.map +1 -0
  140. package/dist/utils/env.js +35 -0
  141. package/dist/utils/env.js.map +1 -0
  142. package/dist/utils/keys.d.ts +16 -0
  143. package/dist/utils/keys.d.ts.map +1 -0
  144. package/dist/utils/keys.js +155 -0
  145. package/dist/utils/keys.js.map +1 -0
  146. package/dist/utils/platform.d.ts +16 -0
  147. package/dist/utils/platform.d.ts.map +1 -0
  148. package/dist/utils/platform.js +41 -0
  149. package/dist/utils/platform.js.map +1 -0
  150. package/dist/utils/session-logger.d.ts +31 -0
  151. package/dist/utils/session-logger.d.ts.map +1 -0
  152. package/dist/utils/session-logger.js +125 -0
  153. package/dist/utils/session-logger.js.map +1 -0
  154. package/dist/utils/stats.d.ts +46 -0
  155. package/dist/utils/stats.d.ts.map +1 -0
  156. package/dist/utils/stats.js +89 -0
  157. package/dist/utils/stats.js.map +1 -0
  158. package/dist/utils/version.d.ts +2 -0
  159. package/dist/utils/version.d.ts.map +1 -0
  160. package/dist/utils/version.js +9 -0
  161. package/dist/utils/version.js.map +1 -0
  162. package/logo.png +0 -0
  163. package/package.json +61 -0
  164. package/packages/desktop-electron/THIRD-PARTY-NOTICES +56 -0
  165. package/packages/desktop-electron/build/afterPack.cjs +147 -0
  166. package/packages/desktop-electron/package-lock.json +10071 -0
  167. package/packages/desktop-electron/package.json +170 -0
  168. package/packages/desktop-electron/resources/icons/mac/icon.icns +0 -0
  169. package/packages/desktop-electron/resources/icons/png/1024x1024.png +0 -0
  170. package/packages/desktop-electron/resources/icons/png/128x128.png +0 -0
  171. package/packages/desktop-electron/resources/icons/png/16x16.png +0 -0
  172. package/packages/desktop-electron/resources/icons/png/24x24.png +0 -0
  173. package/packages/desktop-electron/resources/icons/png/256x256.png +0 -0
  174. package/packages/desktop-electron/resources/icons/png/32x32.png +0 -0
  175. package/packages/desktop-electron/resources/icons/png/48x48.png +0 -0
  176. package/packages/desktop-electron/resources/icons/png/512x512.png +0 -0
  177. package/packages/desktop-electron/resources/icons/png/64x64.png +0 -0
  178. package/packages/desktop-electron/resources/icons/win/icon.ico +0 -0
  179. package/packages/desktop-electron/scripts/download-models.js +97 -0
  180. package/packages/desktop-electron/scripts/prepare-sandbox-bins.js +186 -0
  181. package/packages/desktop-electron/tests/main/ai-detection/additionalFunctions.test.ts +224 -0
  182. package/packages/desktop-electron/tests/main/ai-detection/checkOverridePrefix.test.ts +162 -0
  183. package/packages/desktop-electron/tests/main/ai-detection/classifyInput.test.ts +132 -0
  184. package/packages/desktop-electron/tests/main/ai-detection/detectTypos.test.ts +342 -0
  185. package/packages/desktop-electron/tests/main/ai-detection/fixtures/commands.ts +134 -0
  186. package/packages/desktop-electron/tests/main/ai-detection/fixtures/natural-language.ts +133 -0
  187. package/packages/desktop-electron/tests/main/ai-detection/fixtures/typos.ts +123 -0
  188. package/packages/desktop-electron/tests/main/ai-detection/hasValidSubcommand.test.ts +218 -0
  189. package/packages/desktop-electron/tests/main/ai-detection/isCommandNotFound.test.ts +117 -0
  190. package/packages/desktop-electron/tests/main/error-triage/buildTriagePrompt.test.ts +133 -0
  191. package/packages/desktop-electron/tests/main/error-triage/parseTriageResponse.test.ts +123 -0
  192. package/packages/desktop-electron/tests/main/terminal-bridge/battery-optimization.test.ts +243 -0
  193. package/packages/desktop-electron/tests/main/terminal-bridge/command-fast-track.test.ts +292 -0
  194. package/packages/desktop-electron/tests/main/terminal-bridge/default-cwd.test.ts +70 -0
  195. package/packages/desktop-electron/tests/setup.ts +274 -0
  196. package/packages/desktop-electron/tsconfig.json +18 -0
  197. package/packages/desktop-electron/tsconfig.main.json +20 -0
  198. package/packages/desktop-electron/vite.config.ts +19 -0
  199. package/packages/desktop-electron/vitest.config.ts +18 -0
  200. package/tsconfig.json +19 -0
@@ -0,0 +1,752 @@
1
+ import * as pty from "node-pty";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { execSync } from "child_process";
6
+ import xtermHeadless from "@xterm/headless";
7
+ const { Terminal } = xtermHeadless;
8
+ import { getDefaultShell } from "../utils/platform.js";
9
+ import { setEnv } from "../utils/env.js";
10
+ /**
11
+ * Get system locale like Hyper does.
12
+ * On macOS, reads from defaults. On Linux, checks environment.
13
+ */
14
+ function getSystemLocale() {
15
+ // Check if already set to UTF-8
16
+ const lang = process.env.LANG || "";
17
+ if (lang.toLowerCase().includes("utf-8") || lang.toLowerCase().includes("utf8")) {
18
+ return lang;
19
+ }
20
+ try {
21
+ if (process.platform === "darwin") {
22
+ // macOS: read from system preferences
23
+ const output = execSync("defaults read -g AppleLocale 2>/dev/null", {
24
+ encoding: "utf8",
25
+ timeout: 1000,
26
+ }).trim();
27
+ if (output) {
28
+ // Convert format: en_US -> en_US.UTF-8
29
+ return `${output.replace(/-/g, "_")}.UTF-8`;
30
+ }
31
+ }
32
+ }
33
+ catch {
34
+ // Fall through to default
35
+ }
36
+ // Default to en_US.UTF-8
37
+ return "en_US.UTF-8";
38
+ }
39
+ // Custom prompt indicator for brosh (nf-md-palm_tree from Nerd Fonts, U+F1055)
40
+ const PROMPT_INDICATOR = "\uDB84\uDC55";
41
+ /**
42
+ * Terminal session that combines node-pty with xterm.js headless
43
+ * for full terminal emulation
44
+ */
45
+ export class TerminalSession {
46
+ ptyProcess;
47
+ terminal;
48
+ disposed = false;
49
+ dataListeners = [];
50
+ exitListeners = [];
51
+ resizeListeners = [];
52
+ rcFile = null;
53
+ zdotdir = null;
54
+ /**
55
+ * Private constructor - use TerminalSession.create() instead
56
+ */
57
+ constructor() { }
58
+ /**
59
+ * Factory method to create a TerminalSession
60
+ * Use this instead of the constructor to support async sandbox initialization
61
+ */
62
+ static async create(options = {}) {
63
+ const session = new TerminalSession();
64
+ await session.initialize(options);
65
+ return session;
66
+ }
67
+ /**
68
+ * Set up shell-specific prompt customization
69
+ * Returns args to pass to shell and env modifications
70
+ */
71
+ setupShellPrompt(shellName, extraEnv, startupBanner) {
72
+ const env = {
73
+ ...extraEnv,
74
+ };
75
+ setEnv(env, 'BROSH', 'TERMINAL_MCP', '1');
76
+ // Escape banner for use in shell scripts
77
+ const escapeBannerForShell = (banner) => {
78
+ // Escape single quotes and backslashes for shell
79
+ return banner.replace(/'/g, "'\\''");
80
+ };
81
+ if (shellName === "bash" || shellName === "sh") {
82
+ // Create temp rcfile that sources user's .bashrc then sets our prompt
83
+ const homeDir = os.homedir();
84
+ const bannerCmd = startupBanner ? `printf '%s\\n' '${escapeBannerForShell(startupBanner)}'` : "";
85
+ const bashrcContent = `
86
+ # Source user's bashrc if it exists
87
+ [ -f "${homeDir}/.bashrc" ] && source "${homeDir}/.bashrc"
88
+
89
+ # Function to get MCP status for prompt
90
+ __brosh_mcp_status() {
91
+ if [ -f /tmp/brosh-mcp-status ]; then
92
+ local __brosh_st=$(cat /tmp/brosh-mcp-status 2>/dev/null)
93
+ if [ "$__brosh_st" = "enabled" ]; then
94
+ printf '\\033[32m(MCP Enabled)\\033[0m'
95
+ else
96
+ printf '\\033[31m(MCP Disabled)\\033[0m'
97
+ fi
98
+ else
99
+ printf '\\033[31m(MCP Disabled)\\033[0m'
100
+ fi
101
+ }
102
+
103
+ # Set brosh prompt and clear PROMPT_COMMAND so prompt managers
104
+ # (Starship, bash-it, oh-my-bash, etc.) can't override PS1
105
+ PROMPT_COMMAND=""
106
+ PS1="\\[\\033[33m\\]${PROMPT_INDICATOR}\\[\\033[0m\\] \$(__brosh_mcp_status)> "
107
+ # Print startup banner
108
+ ${bannerCmd}
109
+ `;
110
+ this.rcFile = path.join(os.tmpdir(), `brosh-bashrc-${process.pid}`);
111
+ fs.writeFileSync(this.rcFile, bashrcContent);
112
+ return { args: ["--rcfile", this.rcFile], env };
113
+ }
114
+ if (shellName === "zsh") {
115
+ // Create temp ZDOTDIR with .zshrc that sources user's config then sets prompt
116
+ const homeDir = os.homedir();
117
+ this.zdotdir = path.join(os.tmpdir(), `brosh-zsh-${process.pid}`);
118
+ fs.mkdirSync(this.zdotdir, { recursive: true });
119
+ const bannerCmd = startupBanner ? `printf '%s\\n' '${escapeBannerForShell(startupBanner)}'` : "";
120
+ const zshrcContent = `
121
+ # Disable p10k instant prompt before sourcing user config
122
+ # (our banner output during init would trigger warnings otherwise)
123
+ typeset -g POWERLEVEL9K_INSTANT_PROMPT=off
124
+
125
+ # Reset ZDOTDIR so nested zsh uses normal config
126
+ export ZDOTDIR="${homeDir}"
127
+ # Source user's zshrc if it exists
128
+ [ -f "${homeDir}/.zshrc" ] && source "${homeDir}/.zshrc"
129
+
130
+ # Neuter prompt managers that use precmd hooks to override PROMPT.
131
+ # We source user config for aliases/PATH/functions, but brosh uses its own prompt.
132
+ # Covers: Powerlevel10k, Starship, Pure, Spaceship, Oh My Zsh themes
133
+ (( \${+functions[_p9k_precmd]} )) && _p9k_precmd() { }
134
+ (( \${+functions[_p9k_preexec]} )) && _p9k_preexec() { }
135
+ (( \${+functions[starship_precmd]} )) && starship_precmd() { }
136
+ (( \${+functions[starship_preexec]} )) && starship_preexec() { }
137
+ (( \${+functions[prompt_pure_precmd]} )) && prompt_pure_precmd() { }
138
+ (( \${+functions[spaceship_precmd]} )) && spaceship_precmd() { }
139
+
140
+ # Clear all precmd hooks except ours to prevent any remaining prompt overrides
141
+ precmd_functions=()
142
+
143
+ # Function to get MCP status for prompt
144
+ __brosh_mcp_status() {
145
+ if [[ -f /tmp/brosh-mcp-status ]]; then
146
+ local __brosh_st=$(<"/tmp/brosh-mcp-status" 2>/dev/null)
147
+ if [[ "$__brosh_st" = "enabled" ]]; then
148
+ print -n '%F{green}(MCP Enabled)%f'
149
+ else
150
+ print -n '%F{red}(MCP Disabled)%f'
151
+ fi
152
+ else
153
+ print -n '%F{red}(MCP Disabled)%f'
154
+ fi
155
+ }
156
+
157
+ # Enable prompt substitution for dynamic status
158
+ setopt PROMPT_SUBST
159
+
160
+ # Set brosh prompt with dynamic MCP status
161
+ PROMPT='%F{yellow}${PROMPT_INDICATOR}%f $(__brosh_mcp_status)> '
162
+ RPROMPT=""
163
+ # Print startup banner
164
+ ${bannerCmd}
165
+ `;
166
+ fs.writeFileSync(path.join(this.zdotdir, ".zshrc"), zshrcContent);
167
+ env.ZDOTDIR = this.zdotdir;
168
+ return { args: [], env };
169
+ }
170
+ // PowerShell (pwsh is PowerShell Core, powershell is Windows PowerShell)
171
+ if (shellName === "powershell" ||
172
+ shellName === "powershell.exe" ||
173
+ shellName === "pwsh" ||
174
+ shellName === "pwsh.exe") {
175
+ setEnv(env, 'BROSH_PROMPT', 'TERMINAL_MCP_PROMPT', '1');
176
+ return { args: ["-NoLogo"], env };
177
+ }
178
+ // Windows cmd.exe
179
+ if (shellName === "cmd" || shellName === "cmd.exe") {
180
+ env.PROMPT = `\x1b[33m${PROMPT_INDICATOR}\x1b[0m $P$G`;
181
+ return { args: [], env };
182
+ }
183
+ // For other shells, just set env vars and hope for the best
184
+ env.PS1 = `\x1b[33m${PROMPT_INDICATOR}\x1b[0m $ `;
185
+ return { args: [], env };
186
+ }
187
+ /**
188
+ * Get a list of available UTF-8 locales on the system.
189
+ * Returns the best one to use, preferring the user's existing locale if valid.
190
+ */
191
+ getAvailableUtf8Locale() {
192
+ const isUtf8 = (locale) => locale.toLowerCase().includes("utf-8") || locale.toLowerCase().includes("utf8");
193
+ // Check if user already has a UTF-8 locale set
194
+ const userLang = process.env.LANG || "";
195
+ const userLcAll = process.env.LC_ALL || "";
196
+ if (isUtf8(userLcAll))
197
+ return userLcAll;
198
+ if (isUtf8(userLang))
199
+ return userLang;
200
+ // Try to detect available locales
201
+ try {
202
+ const { execSync } = require("child_process");
203
+ const localeOutput = execSync("locale -a 2>/dev/null", {
204
+ encoding: "utf8",
205
+ timeout: 1000,
206
+ });
207
+ const locales = localeOutput.split("\n").filter((l) => isUtf8(l));
208
+ // Prefer C.UTF-8 as it's most portable (available on most Linux systems)
209
+ if (locales.includes("C.UTF-8"))
210
+ return "C.UTF-8";
211
+ if (locales.includes("C.utf8"))
212
+ return "C.utf8";
213
+ // Then try POSIX UTF-8 variants
214
+ if (locales.includes("POSIX.UTF-8"))
215
+ return "POSIX.UTF-8";
216
+ // Then try en_US.UTF-8 variants
217
+ const enUs = locales.find((l) => l.startsWith("en_US") && isUtf8(l));
218
+ if (enUs)
219
+ return enUs;
220
+ // Use any available UTF-8 locale
221
+ if (locales.length > 0)
222
+ return locales[0];
223
+ }
224
+ catch {
225
+ // locale command failed, use platform-specific defaults
226
+ }
227
+ // Platform-specific fallbacks
228
+ if (process.platform === "darwin") {
229
+ // macOS always has en_US.UTF-8
230
+ return "en_US.UTF-8";
231
+ }
232
+ // Linux/other: C.UTF-8 is the most portable
233
+ return "C.UTF-8";
234
+ }
235
+ /**
236
+ * Ensure proper UTF-8 locale settings for the terminal.
237
+ * Electron apps launched from Finder on macOS don't inherit shell locale settings,
238
+ * which breaks programs like mosh that require UTF-8.
239
+ */
240
+ ensureUtf8Locale(env) {
241
+ const result = { ...env };
242
+ const isUtf8 = (locale) => locale.toLowerCase().includes("utf-8") || locale.toLowerCase().includes("utf8");
243
+ // Check current locale settings
244
+ const lang = process.env.LANG || "";
245
+ const lcCtype = process.env.LC_CTYPE || "";
246
+ const lcAll = process.env.LC_ALL || "";
247
+ // If already UTF-8, don't change anything
248
+ if (isUtf8(lcAll) || (isUtf8(lang) && isUtf8(lcCtype))) {
249
+ return result;
250
+ }
251
+ // Get a valid UTF-8 locale
252
+ const targetLocale = this.getAvailableUtf8Locale();
253
+ // Set LANG if not UTF-8
254
+ if (!isUtf8(lang)) {
255
+ result.LANG = targetLocale;
256
+ }
257
+ // Set LC_CTYPE specifically for character encoding (most important for mosh)
258
+ if (!isUtf8(lcCtype)) {
259
+ result.LC_CTYPE = targetLocale;
260
+ }
261
+ // Clear LC_ALL if it's set to a non-UTF-8 value (it overrides everything)
262
+ if (lcAll && !isUtf8(lcAll)) {
263
+ result.LC_ALL = "";
264
+ }
265
+ return result;
266
+ }
267
+ /**
268
+ * Initialize the terminal session
269
+ * This is called by the create() factory method
270
+ */
271
+ async initialize(options) {
272
+ const cols = options.cols ?? 120;
273
+ const rows = options.rows ?? 40;
274
+ const shell = options.shell ?? getDefaultShell();
275
+ // Create headless terminal emulator
276
+ this.terminal = new Terminal({
277
+ cols,
278
+ rows,
279
+ scrollback: 1000,
280
+ allowProposedApi: true,
281
+ });
282
+ // Determine shell type and set up custom prompt (unless nativeShell is enabled)
283
+ const shellName = path.basename(shell);
284
+ let args = [];
285
+ let env = { ...options.env };
286
+ console.log(`[session] shell=${shell}, shellName=${shellName}, nativeShell=${options.nativeShell}`);
287
+ if (options.nativeShell) {
288
+ // Use native shell without customization - just set BROSH env var
289
+ // Spawn as login shell so user's profile is sourced (sets up PATH, aliases, etc.)
290
+ setEnv(env, 'BROSH', 'TERMINAL_MCP', '1');
291
+ if (shellName === "bash" || shellName === "sh") {
292
+ // Bash: use --rcfile with a temp file that sources the user's profile,
293
+ // injects OSC 7 (cwd reporting) and OSC 133 (command marks for error detection)
294
+ const homeDir = os.homedir();
295
+ const bashRcContent = `
296
+ # Source user's login profile for PATH, aliases, etc.
297
+ [ -f "${homeDir}/.bash_profile" ] && source "${homeDir}/.bash_profile" || {
298
+ [ -f "${homeDir}/.bash_login" ] && source "${homeDir}/.bash_login" || {
299
+ [ -f "${homeDir}/.profile" ] && source "${homeDir}/.profile"
300
+ }
301
+ }
302
+ # Source .bashrc if not already sourced by the profile above
303
+ [ -f "${homeDir}/.bashrc" ] && source "${homeDir}/.bashrc"
304
+
305
+ # Emit OSC 7 (current working directory) on every prompt
306
+ # This lets the terminal track cwd changes for status bar badges
307
+ __brosh_osc7() {
308
+ printf '\\e]7;file://%s%s\\e\\\\' "$HOSTNAME" "$PWD"
309
+ }
310
+
311
+ # OSC 133 shell integration (command marks for error detection)
312
+ # A = prompt start, C = output start, D;exitcode = command finished
313
+ __brosh_cmd_executed=""
314
+ __brosh_precmd() {
315
+ local __brosh_exit=$?
316
+ if [[ -n "$__brosh_cmd_executed" ]]; then
317
+ printf '\\e]133;D;%d\\a' "$__brosh_exit"
318
+ __brosh_cmd_executed=""
319
+ fi
320
+ printf '\\e]133;A\\a'
321
+ }
322
+ PROMPT_COMMAND="__brosh_precmd;__brosh_osc7\${PROMPT_COMMAND:+;\\$PROMPT_COMMAND}"
323
+
324
+ # DEBUG trap for preexec (marks output start when command begins executing)
325
+ trap '
326
+ if [[ -z "$__brosh_cmd_executed" && "$BASH_COMMAND" != "__brosh_precmd" && "$BASH_COMMAND" != "__brosh_osc7" ]]; then
327
+ __brosh_cmd_executed=1
328
+ printf '"'"'\\e]133;C\\a'"'"'
329
+ fi
330
+ ' DEBUG
331
+ `;
332
+ this.rcFile = path.join(os.tmpdir(), `brosh-bashrc-${process.pid}-${Date.now()}`);
333
+ fs.writeFileSync(this.rcFile, bashRcContent);
334
+ args = ["--rcfile", this.rcFile];
335
+ }
336
+ else if (shellName === "zsh") {
337
+ // Zsh: create ZDOTDIR wrapper that sources user's config and adds
338
+ // OSC 133 shell integration hooks (non-destructive via add-zsh-hook)
339
+ const userZdotdir = process.env.ZDOTDIR || os.homedir();
340
+ this.zdotdir = path.join(os.tmpdir(), `brosh-zsh-${process.pid}-${Date.now()}`);
341
+ fs.mkdirSync(this.zdotdir, { recursive: true });
342
+ console.log(`[session] Created ZDOTDIR: ${this.zdotdir}, user ZDOTDIR: ${userZdotdir}`);
343
+ // .zshenv - runs for all zsh invocations (earliest rc file)
344
+ // Set HISTFILE here before zsh initializes history. Since ZDOTDIR is a
345
+ // temp dir, zsh's default HISTFILE ($ZDOTDIR/.zsh_history) would point
346
+ // to a nonexistent file. Must be set before user's .zshenv in case they
347
+ // rely on the default.
348
+ fs.writeFileSync(path.join(this.zdotdir, ".zshenv"), `typeset -g __BROSH_WRAPPER_ZDOTDIR="$ZDOTDIR"\n` +
349
+ `export ZDOTDIR="${userZdotdir}"\n` +
350
+ `HISTFILE="${userZdotdir}/.zsh_history"\n` +
351
+ `[[ -f "${userZdotdir}/.zshenv" ]] && source "${userZdotdir}/.zshenv"\n` +
352
+ `export ZDOTDIR="$__BROSH_WRAPPER_ZDOTDIR"\n` +
353
+ `unset __BROSH_WRAPPER_ZDOTDIR\n` +
354
+ `[[ -z "$HISTFILE" ]] && HISTFILE="${userZdotdir}/.zsh_history"\n` +
355
+ `[[ "$HISTFILE" == "$ZDOTDIR/.zsh_history" ]] && HISTFILE="${userZdotdir}/.zsh_history"\n`);
356
+ // .zprofile - runs for login shells (before .zshrc)
357
+ fs.writeFileSync(path.join(this.zdotdir, ".zprofile"), `typeset -g __BROSH_WRAPPER_ZDOTDIR="$ZDOTDIR"\n` +
358
+ `export ZDOTDIR="${userZdotdir}"\n` +
359
+ `[[ -f "${userZdotdir}/.zprofile" ]] && source "${userZdotdir}/.zprofile"\n` +
360
+ `export ZDOTDIR="$__BROSH_WRAPPER_ZDOTDIR"\n` +
361
+ `unset __BROSH_WRAPPER_ZDOTDIR\n` +
362
+ `[[ -z "$HISTFILE" ]] && HISTFILE="${userZdotdir}/.zsh_history"\n` +
363
+ `[[ "$HISTFILE" == "$ZDOTDIR/.zsh_history" ]] && HISTFILE="${userZdotdir}/.zsh_history"\n`);
364
+ // .zshrc - runs for interactive shells
365
+ const zshrcContent = `
366
+ # Keep wrapper ZDOTDIR so brosh startup files still run.
367
+ typeset -g __BROSH_WRAPPER_ZDOTDIR="$ZDOTDIR"
368
+
369
+ # Make user config see the real ZDOTDIR.
370
+ # Many zsh configs derive HISTFILE from ZDOTDIR.
371
+ export ZDOTDIR="${userZdotdir}"
372
+ [[ -f "${userZdotdir}/.zshrc" ]] && source "${userZdotdir}/.zshrc"
373
+
374
+ # Restore wrapper ZDOTDIR for the remainder of startup.
375
+ export ZDOTDIR="$__BROSH_WRAPPER_ZDOTDIR"
376
+ unset __BROSH_WRAPPER_ZDOTDIR
377
+
378
+ # If user config left HISTFILE empty or pointing to wrapper temp ZDOTDIR,
379
+ # reset it to the user's default history file.
380
+ [[ -z "$HISTFILE" ]] && HISTFILE="${userZdotdir}/.zsh_history"
381
+ [[ "$HISTFILE" == "$ZDOTDIR/.zsh_history" ]] && HISTFILE="${userZdotdir}/.zsh_history"
382
+
383
+ # HISTFILE is set in .zshenv (before zsh initializes history).
384
+ # Set size defaults here; user's .zshrc can override.
385
+ : \${HISTSIZE:=50000}
386
+ : \${SAVEHIST:=10000}
387
+
388
+ # OSC 133 shell integration (command marks for error detection)
389
+ # A = prompt start, C = output start, D;exitcode = command finished
390
+ # Uses add-zsh-hook so it doesn't interfere with user's hooks
391
+ __brosh_cmd_executed=""
392
+ __brosh_precmd() {
393
+ local exit_code=$?
394
+ if [[ -n "$__brosh_cmd_executed" ]]; then
395
+ printf '\\e]133;D;%d\\a' "$exit_code"
396
+ fi
397
+ __brosh_cmd_executed=""
398
+ printf '\\e]133;A\\a'
399
+ }
400
+ __brosh_preexec() {
401
+ __brosh_cmd_executed=1
402
+ printf '\\e]133;C\\a'
403
+ }
404
+ autoload -Uz add-zsh-hook
405
+ add-zsh-hook precmd __brosh_precmd
406
+ add-zsh-hook preexec __brosh_preexec
407
+ `;
408
+ fs.writeFileSync(path.join(this.zdotdir, ".zshrc"), zshrcContent);
409
+ // .zlogin - runs for login shells (after .zshrc) — last rc file
410
+ // Force-read history here, after restoring the user's ZDOTDIR context.
411
+ fs.writeFileSync(path.join(this.zdotdir, ".zlogin"), `typeset -g __BROSH_WRAPPER_ZDOTDIR="$ZDOTDIR"\n` +
412
+ `export ZDOTDIR="${userZdotdir}"\n` +
413
+ `[[ -f "${userZdotdir}/.zlogin" ]] && source "${userZdotdir}/.zlogin"\n` +
414
+ `export ZDOTDIR="$__BROSH_WRAPPER_ZDOTDIR"\n` +
415
+ `unset __BROSH_WRAPPER_ZDOTDIR\n` +
416
+ `[[ -z "$HISTFILE" ]] && HISTFILE="${userZdotdir}/.zsh_history"\n` +
417
+ `[[ "$HISTFILE" == "$ZDOTDIR/.zsh_history" ]] && HISTFILE="${userZdotdir}/.zsh_history"\n` +
418
+ `[[ -s "\$HISTFILE" ]] && fc -R "\$HISTFILE"\n` +
419
+ // Keep runtime ZDOTDIR on the user's path so prompt hooks/plugins
420
+ // that derive HISTFILE from ZDOTDIR don't switch back to wrapper temp dir.
421
+ `export ZDOTDIR="${userZdotdir}"\n`);
422
+ env.ZDOTDIR = this.zdotdir;
423
+ args = ["--login"];
424
+ }
425
+ // Set LANG for local UTF-8 support (like iTerm2's "Set locale environment
426
+ // variables automatically"). By default we only set LANG, not LC_CTYPE,
427
+ // because SSH's SendEnv forwards LC_* variables and remote servers may not
428
+ // have the same locales installed. The system derives LC_CTYPE from LANG locally.
429
+ const systemLocale = getSystemLocale();
430
+ env.LANG = systemLocale;
431
+ // Optionally set LC_CTYPE too (may cause SSH issues with remote servers)
432
+ if (options.setLocaleEnv) {
433
+ env.LC_CTYPE = systemLocale;
434
+ }
435
+ }
436
+ else {
437
+ // Set up brosh custom prompt
438
+ const promptSetup = this.setupShellPrompt(shellName, options.env, options.startupBanner);
439
+ args = promptSetup.args;
440
+ env = promptSetup.env;
441
+ // Ensure UTF-8 locale for MCP mode (headless operation)
442
+ env = this.ensureUtf8Locale(env);
443
+ }
444
+ // Determine spawn command - may be wrapped by sandbox
445
+ let spawnCmd = shell;
446
+ let spawnArgs = args;
447
+ if (options.sandboxController?.isActive()) {
448
+ const wrapped = await options.sandboxController.wrapShellCommand(shell, args);
449
+ spawnCmd = wrapped.cmd;
450
+ spawnArgs = wrapped.args;
451
+ if (process.env.DEBUG_SANDBOX) {
452
+ console.error("[sandbox-debug] Spawn command:", spawnCmd);
453
+ console.error("[sandbox-debug] Spawn args:", spawnArgs.join(" "));
454
+ console.error("[sandbox-debug] CWD:", options.cwd ?? process.cwd());
455
+ }
456
+ }
457
+ // Build the final environment for the PTY process
458
+ // By default, filter out LC_* variables from inherited environment to avoid
459
+ // SSH forwarding issues (SSH's SendEnv LC_* forwards these to remote servers
460
+ // that may not have the same locales). We set LANG which is sufficient for
461
+ // local UTF-8 support - the shell derives other locale settings from LANG.
462
+ const baseEnv = {};
463
+ const filterLcVars = options.nativeShell && !options.setLocaleEnv;
464
+ for (const [key, value] of Object.entries(process.env)) {
465
+ // Skip LC_* variables when filtering is enabled
466
+ if (filterLcVars && key.startsWith("LC_")) {
467
+ continue;
468
+ }
469
+ // Strip npm internal variables — these leak from Electron's parent process
470
+ // (npm run dev/start) and break tools like nvm that check npm_config_prefix
471
+ if (key.startsWith("npm_")) {
472
+ continue;
473
+ }
474
+ if (value !== undefined) {
475
+ baseEnv[key] = value;
476
+ }
477
+ }
478
+ // Spawn PTY process
479
+ this.ptyProcess = pty.spawn(spawnCmd, spawnArgs, {
480
+ name: "xterm-256color",
481
+ cols,
482
+ rows,
483
+ cwd: options.cwd ?? process.cwd(),
484
+ env: { ...baseEnv, ...env },
485
+ });
486
+ // Pipe PTY output to terminal emulator and listeners
487
+ this.ptyProcess.onData((data) => {
488
+ if (!this.disposed) {
489
+ this.terminal.write(data);
490
+ // Notify all data listeners
491
+ for (const listener of this.dataListeners) {
492
+ listener(data);
493
+ }
494
+ }
495
+ });
496
+ this.ptyProcess.onExit(({ exitCode }) => {
497
+ this.disposed = true;
498
+ for (const listener of this.exitListeners) {
499
+ listener(exitCode);
500
+ }
501
+ });
502
+ }
503
+ /**
504
+ * Subscribe to PTY output data
505
+ */
506
+ onData(listener) {
507
+ this.dataListeners.push(listener);
508
+ }
509
+ /**
510
+ * Subscribe to PTY exit
511
+ */
512
+ onExit(listener) {
513
+ this.exitListeners.push(listener);
514
+ }
515
+ /**
516
+ * Subscribe to terminal resize events
517
+ */
518
+ onResize(listener) {
519
+ this.resizeListeners.push(listener);
520
+ }
521
+ /**
522
+ * Write data to the terminal (simulates typing)
523
+ */
524
+ write(data) {
525
+ if (this.disposed) {
526
+ throw new Error("Terminal session has been disposed");
527
+ }
528
+ this.ptyProcess.write(data);
529
+ }
530
+ /**
531
+ * Get the current terminal buffer content as plain text
532
+ */
533
+ getContent() {
534
+ if (this.disposed) {
535
+ throw new Error("Terminal session has been disposed");
536
+ }
537
+ const buffer = this.terminal.buffer.active;
538
+ const lines = [];
539
+ // Get all lines from the buffer (including scrollback)
540
+ for (let i = 0; i < buffer.length; i++) {
541
+ const line = buffer.getLine(i);
542
+ if (line) {
543
+ lines.push(line.translateToString(true));
544
+ }
545
+ }
546
+ // Trim trailing empty lines
547
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") {
548
+ lines.pop();
549
+ }
550
+ return lines.join("\n");
551
+ }
552
+ /**
553
+ * Get only the visible viewport content
554
+ */
555
+ getVisibleContent() {
556
+ if (this.disposed) {
557
+ throw new Error("Terminal session has been disposed");
558
+ }
559
+ const buffer = this.terminal.buffer.active;
560
+ const lines = [];
561
+ const baseY = buffer.baseY;
562
+ for (let i = 0; i < this.terminal.rows; i++) {
563
+ const line = buffer.getLine(baseY + i);
564
+ if (line) {
565
+ lines.push(line.translateToString(true));
566
+ }
567
+ }
568
+ return lines.join("\n");
569
+ }
570
+ /**
571
+ * Take a screenshot of the terminal state
572
+ */
573
+ takeScreenshot() {
574
+ if (this.disposed) {
575
+ throw new Error("Terminal session has been disposed");
576
+ }
577
+ const buffer = this.terminal.buffer.active;
578
+ return {
579
+ content: this.getVisibleContent(),
580
+ cursor: {
581
+ x: buffer.cursorX,
582
+ y: buffer.cursorY,
583
+ },
584
+ dimensions: {
585
+ cols: this.terminal.cols,
586
+ rows: this.terminal.rows,
587
+ },
588
+ };
589
+ }
590
+ /**
591
+ * Clear the terminal screen
592
+ */
593
+ clear() {
594
+ if (this.disposed) {
595
+ throw new Error("Terminal session has been disposed");
596
+ }
597
+ this.terminal.clear();
598
+ }
599
+ /**
600
+ * Resize the terminal
601
+ */
602
+ resize(cols, rows) {
603
+ if (this.disposed) {
604
+ throw new Error("Terminal session has been disposed");
605
+ }
606
+ this.terminal.resize(cols, rows);
607
+ this.ptyProcess.resize(cols, rows);
608
+ // Notify all resize listeners
609
+ for (const listener of this.resizeListeners) {
610
+ listener(cols, rows);
611
+ }
612
+ }
613
+ /**
614
+ * Check if the session is still active
615
+ */
616
+ isActive() {
617
+ return !this.disposed;
618
+ }
619
+ /**
620
+ * Get terminal dimensions
621
+ */
622
+ getDimensions() {
623
+ return {
624
+ cols: this.terminal.cols,
625
+ rows: this.terminal.rows,
626
+ };
627
+ }
628
+ /**
629
+ * Get the current foreground process name
630
+ * On macOS/Linux this returns the actual process running in the terminal
631
+ */
632
+ getProcess() {
633
+ if (this.disposed) {
634
+ return "shell";
635
+ }
636
+ try {
637
+ // node-pty's process property returns the current foreground process
638
+ return this.ptyProcess.process || "shell";
639
+ }
640
+ catch {
641
+ return "shell";
642
+ }
643
+ }
644
+ /**
645
+ * Get the current working directory of the foreground process
646
+ * Uses process tree traversal to find the deepest child process
647
+ */
648
+ getCwd() {
649
+ if (this.disposed) {
650
+ return null;
651
+ }
652
+ try {
653
+ const pid = this.ptyProcess.pid;
654
+ // Validate PID before using in shell command
655
+ if (!pid || typeof pid !== "number" || pid <= 0) {
656
+ return null;
657
+ }
658
+ if (process.platform === "darwin") {
659
+ // macOS: Find the foreground process by traversing the process tree
660
+ // When you run `bash` inside zsh, bash is a child of zsh
661
+ // We need to find the deepest child (the actual foreground process)
662
+ // and get its cwd
663
+ //
664
+ // Use pgrep to find child processes, then get the leaf (deepest child)
665
+ // This handles: zsh -> bash -> python, etc.
666
+ const findLeafPid = (parentPid) => {
667
+ try {
668
+ const children = execSync(`pgrep -P ${parentPid} 2>/dev/null`, {
669
+ encoding: "utf8",
670
+ timeout: 500,
671
+ }).trim();
672
+ if (children) {
673
+ // Take the first child and recurse
674
+ const childPid = parseInt(children.split("\n")[0], 10);
675
+ if (childPid > 0) {
676
+ return findLeafPid(childPid);
677
+ }
678
+ }
679
+ }
680
+ catch {
681
+ // No children, this is the leaf
682
+ }
683
+ return parentPid;
684
+ };
685
+ const leafPid = findLeafPid(pid);
686
+ // Get cwd of the leaf process
687
+ const output = execSync(`lsof -a -p ${leafPid} -d cwd 2>/dev/null | awk 'NR==2 {print $NF}'`, {
688
+ encoding: "utf8",
689
+ timeout: 1000,
690
+ }).trim();
691
+ // Validate output looks like a path
692
+ if (output && output.startsWith("/")) {
693
+ return output;
694
+ }
695
+ return null;
696
+ }
697
+ else if (process.platform === "linux") {
698
+ // Linux: Find the foreground process similarly
699
+ const findLeafPid = (parentPid) => {
700
+ try {
701
+ const childrenPath = `/proc/${parentPid}/task/${parentPid}/children`;
702
+ const children = fs.readFileSync(childrenPath, "utf8").trim();
703
+ if (children) {
704
+ const childPid = parseInt(children.split(" ")[0], 10);
705
+ if (childPid > 0) {
706
+ return findLeafPid(childPid);
707
+ }
708
+ }
709
+ }
710
+ catch {
711
+ // No children or can't read
712
+ }
713
+ return parentPid;
714
+ };
715
+ const leafPid = findLeafPid(pid);
716
+ return fs.readlinkSync(`/proc/${leafPid}/cwd`);
717
+ }
718
+ return null;
719
+ }
720
+ catch {
721
+ return null;
722
+ }
723
+ }
724
+ /**
725
+ * Dispose of the terminal session
726
+ */
727
+ dispose() {
728
+ if (!this.disposed) {
729
+ this.disposed = true;
730
+ this.ptyProcess.kill();
731
+ this.terminal.dispose();
732
+ // Clean up temp rc files
733
+ if (this.rcFile) {
734
+ try {
735
+ fs.unlinkSync(this.rcFile);
736
+ }
737
+ catch {
738
+ // Ignore cleanup errors
739
+ }
740
+ }
741
+ if (this.zdotdir) {
742
+ try {
743
+ fs.rmSync(this.zdotdir, { recursive: true });
744
+ }
745
+ catch {
746
+ // Ignore cleanup errors
747
+ }
748
+ }
749
+ }
750
+ }
751
+ }
752
+ //# sourceMappingURL=session.js.map