mstro-app 0.2.0 → 0.3.1

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 (153) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +79 -49
  4. package/bin/mstro.js +305 -39
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +137 -30
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +2 -2
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts +6 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +59 -4
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +20 -1
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +20 -2
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +224 -31
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/index.js +6 -4
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/mcp/bouncer-cli.js +53 -14
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +70 -7
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/security-audit.d.ts +3 -3
  37. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  38. package/dist/server/mcp/security-audit.js.map +1 -1
  39. package/dist/server/mcp/server.js +3 -2
  40. package/dist/server/mcp/server.js.map +1 -1
  41. package/dist/server/services/analytics.d.ts +2 -2
  42. package/dist/server/services/analytics.d.ts.map +1 -1
  43. package/dist/server/services/analytics.js +13 -1
  44. package/dist/server/services/analytics.js.map +1 -1
  45. package/dist/server/services/files.js +7 -7
  46. package/dist/server/services/files.js.map +1 -1
  47. package/dist/server/services/pathUtils.js +1 -1
  48. package/dist/server/services/pathUtils.js.map +1 -1
  49. package/dist/server/services/platform.d.ts +2 -2
  50. package/dist/server/services/platform.d.ts.map +1 -1
  51. package/dist/server/services/platform.js +13 -1
  52. package/dist/server/services/platform.js.map +1 -1
  53. package/dist/server/services/sentry.d.ts +1 -1
  54. package/dist/server/services/sentry.d.ts.map +1 -1
  55. package/dist/server/services/sentry.js.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.d.ts +12 -0
  57. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  58. package/dist/server/services/terminal/pty-manager.js +81 -6
  59. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  60. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  62. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  63. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  64. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  65. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  66. package/dist/server/services/websocket/file-utils.js +27 -8
  67. package/dist/server/services/websocket/file-utils.js.map +1 -1
  68. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  69. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/git-handlers.js +797 -0
  71. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  73. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  75. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  77. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  79. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  81. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  82. package/dist/server/services/websocket/handler-context.js +4 -0
  83. package/dist/server/services/websocket/handler-context.js.map +1 -0
  84. package/dist/server/services/websocket/handler.d.ts +27 -359
  85. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  86. package/dist/server/services/websocket/handler.js +68 -2329
  87. package/dist/server/services/websocket/handler.js.map +1 -1
  88. package/dist/server/services/websocket/index.d.ts +1 -1
  89. package/dist/server/services/websocket/index.d.ts.map +1 -1
  90. package/dist/server/services/websocket/index.js.map +1 -1
  91. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  92. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  93. package/dist/server/services/websocket/session-handlers.js +508 -0
  94. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  95. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  96. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  97. package/dist/server/services/websocket/settings-handlers.js +125 -0
  98. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  99. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  100. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  101. package/dist/server/services/websocket/tab-handlers.js +131 -0
  102. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  103. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  104. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  105. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  106. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  107. package/dist/server/services/websocket/types.d.ts +63 -2
  108. package/dist/server/services/websocket/types.d.ts.map +1 -1
  109. package/dist/server/utils/agent-manager.d.ts +22 -2
  110. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  111. package/dist/server/utils/agent-manager.js +2 -2
  112. package/dist/server/utils/agent-manager.js.map +1 -1
  113. package/dist/server/utils/port-manager.js.map +1 -1
  114. package/hooks/bouncer.sh +17 -3
  115. package/package.json +7 -3
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +172 -43
  118. package/server/cli/headless/mcp-config.ts +8 -8
  119. package/server/cli/headless/runner.ts +57 -4
  120. package/server/cli/headless/stall-assessor.ts +25 -0
  121. package/server/cli/headless/tool-watchdog.ts +33 -25
  122. package/server/cli/headless/types.ts +11 -2
  123. package/server/cli/improvisation-session-manager.ts +285 -37
  124. package/server/index.ts +15 -13
  125. package/server/mcp/README.md +59 -67
  126. package/server/mcp/bouncer-cli.ts +73 -20
  127. package/server/mcp/bouncer-integration.ts +99 -16
  128. package/server/mcp/security-audit.ts +4 -4
  129. package/server/mcp/server.ts +6 -5
  130. package/server/services/analytics.ts +16 -4
  131. package/server/services/files.ts +13 -13
  132. package/server/services/pathUtils.ts +2 -2
  133. package/server/services/platform.ts +17 -6
  134. package/server/services/sentry.ts +1 -1
  135. package/server/services/terminal/pty-manager.ts +88 -11
  136. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  137. package/server/services/websocket/file-utils.ts +28 -9
  138. package/server/services/websocket/git-handlers.ts +924 -0
  139. package/server/services/websocket/git-pr-handlers.ts +363 -0
  140. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  141. package/server/services/websocket/handler-context.ts +44 -0
  142. package/server/services/websocket/handler.ts +85 -2680
  143. package/server/services/websocket/index.ts +1 -1
  144. package/server/services/websocket/session-handlers.ts +575 -0
  145. package/server/services/websocket/settings-handlers.ts +150 -0
  146. package/server/services/websocket/tab-handlers.ts +150 -0
  147. package/server/services/websocket/terminal-handlers.ts +277 -0
  148. package/server/services/websocket/types.ts +137 -0
  149. package/server/utils/agent-manager.ts +6 -6
  150. package/server/utils/port-manager.ts +1 -1
  151. package/bin/release.sh +0 -110
  152. package/server/services/platform.test.ts +0 -1304
  153. package/server/services/websocket/handler.test.ts +0 -20
@@ -73,7 +73,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
73
73
 
74
74
  const { tool_name, input } = request.params.arguments as {
75
75
  tool_name: string;
76
- input: Record<string, any>;
76
+ input: Record<string, unknown>;
77
77
  };
78
78
 
79
79
  console.error(`[MCP Bouncer] Analyzing ${tool_name} request...`);
@@ -84,7 +84,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
84
84
 
85
85
  // Extract file path with multiple property name support
86
86
  // Claude Code may use file_path, filePath, or path depending on context
87
- const getFilePath = (inp: Record<string, any>) =>
87
+ const getFilePath = (inp: Record<string, unknown>) =>
88
88
  inp.file_path || inp.filePath || inp.path;
89
89
 
90
90
  if (tool_name === 'Bash' && input.command) {
@@ -141,8 +141,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
141
141
  },
142
142
  ],
143
143
  };
144
- } catch (error: any) {
145
- console.error(`[MCP Bouncer] Error: ${error.message}`);
144
+ } catch (error: unknown) {
145
+ const errorMessage = error instanceof Error ? error.message : String(error);
146
+ console.error(`[MCP Bouncer] Error: ${errorMessage}`);
146
147
 
147
148
  // Fail-safe: deny on error
148
149
  return {
@@ -151,7 +152,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
151
152
  type: 'text',
152
153
  text: JSON.stringify({
153
154
  behavior: 'deny',
154
- message: `Security analysis failed: ${error.message}. Denying for safety.`,
155
+ message: `Security analysis failed: ${errorMessage}. Denying for safety.`,
155
156
  }),
156
157
  },
157
158
  ],
@@ -23,7 +23,19 @@ import { getClientId } from './client-id.js'
23
23
 
24
24
  const MSTRO_DIR = join(homedir(), '.mstro')
25
25
  const CONFIG_FILE = join(MSTRO_DIR, 'config.json')
26
- const PLATFORM_URL = process.env.PLATFORM_URL || 'https://api.mstro.app'
26
+
27
+ // Read SERVER_URL from ~/.mstro/.env if it exists (for local dev)
28
+ function getServerUrl(): string {
29
+ try {
30
+ const envPath = join(MSTRO_DIR, '.env')
31
+ const content = readFileSync(envPath, 'utf-8')
32
+ const match = content.match(/^SERVER_URL=(.+)$/m)
33
+ if (match) return match[1].trim()
34
+ } catch {}
35
+ return 'https://api.mstro.app'
36
+ }
37
+
38
+ const PLATFORM_URL = process.env.PLATFORM_URL || getServerUrl()
27
39
 
28
40
  let client: PostHog | null = null
29
41
  let telemetryEnabled: boolean | null = null
@@ -146,7 +158,7 @@ function getDistinctId(): string {
146
158
  /**
147
159
  * Get common properties included with all events
148
160
  */
149
- function getCommonProperties(): Record<string, any> {
161
+ function getCommonProperties(): Record<string, unknown> {
150
162
  return {
151
163
  os: platform(),
152
164
  arch: arch(),
@@ -159,7 +171,7 @@ function getCommonProperties(): Record<string, any> {
159
171
  /**
160
172
  * Track a custom event
161
173
  */
162
- export function trackEvent(event: string, properties?: Record<string, any>): void {
174
+ export function trackEvent(event: string, properties?: Record<string, unknown>): void {
163
175
  if (!client || !isTelemetryEnabled()) return
164
176
 
165
177
  client.capture({
@@ -175,7 +187,7 @@ export function trackEvent(event: string, properties?: Record<string, any>): voi
175
187
  /**
176
188
  * Identify a user (call after login)
177
189
  */
178
- export function identifyUser(userId: string, properties?: Record<string, any>): void {
190
+ export function identifyUser(userId: string, properties?: Record<string, unknown>): void {
179
191
  if (!client || !isTelemetryEnabled()) return
180
192
 
181
193
  // Link the client ID to the user ID
@@ -332,9 +332,9 @@ export function listDirectory(
332
332
  success: true,
333
333
  entries: directoryEntries
334
334
  }
335
- } catch (error: any) {
335
+ } catch (error: unknown) {
336
336
  // Handle permission errors gracefully
337
- if (error.code === 'EACCES') {
337
+ if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EACCES') {
338
338
  return {
339
339
  success: false,
340
340
  error: 'Permission denied'
@@ -344,7 +344,7 @@ export function listDirectory(
344
344
  console.error('[FileService] Error listing directory:', error)
345
345
  return {
346
346
  success: false,
347
- error: error.message || 'Failed to list directory'
347
+ error: error instanceof Error ? error.message : 'Failed to list directory'
348
348
  }
349
349
  }
350
350
  }
@@ -408,11 +408,11 @@ export function writeFile(
408
408
  success: true,
409
409
  path: resolvedPath.replace(`${workingDir}/`, '')
410
410
  }
411
- } catch (error: any) {
411
+ } catch (error: unknown) {
412
412
  console.error('[FileService] Error writing file:', error)
413
413
  return {
414
414
  success: false,
415
- error: error.message || 'Failed to write file'
415
+ error: error instanceof Error ? error.message : 'Failed to write file'
416
416
  }
417
417
  }
418
418
  }
@@ -471,11 +471,11 @@ export function createFile(
471
471
  success: true,
472
472
  path: resolvedPath.replace(`${workingDir}/`, '')
473
473
  }
474
- } catch (error: any) {
474
+ } catch (error: unknown) {
475
475
  console.error('[FileService] Error creating file:', error)
476
476
  return {
477
477
  success: false,
478
- error: error.message || 'Failed to create file'
478
+ error: error instanceof Error ? error.message : 'Failed to create file'
479
479
  }
480
480
  }
481
481
  }
@@ -536,11 +536,11 @@ export function createDirectory(
536
536
  success: true,
537
537
  path: resolvedPath.replace(`${workingDir}/`, '')
538
538
  }
539
- } catch (error: any) {
539
+ } catch (error: unknown) {
540
540
  console.error('[FileService] Error creating directory:', error)
541
541
  return {
542
542
  success: false,
543
- error: error.message || 'Failed to create directory'
543
+ error: error instanceof Error ? error.message : 'Failed to create directory'
544
544
  }
545
545
  }
546
546
  }
@@ -618,11 +618,11 @@ export function deleteFile(
618
618
  success: true,
619
619
  path: resolvedPath.replace(`${workingDir}/`, '')
620
620
  }
621
- } catch (error: any) {
621
+ } catch (error: unknown) {
622
622
  console.error('[FileService] Error deleting file:', error)
623
623
  return {
624
624
  success: false,
625
- error: error.message || 'Failed to delete'
625
+ error: error instanceof Error ? error.message : 'Failed to delete'
626
626
  }
627
627
  }
628
628
  }
@@ -700,11 +700,11 @@ export function renameFile(
700
700
  success: true,
701
701
  path: resolvedNewPath.replace(`${workingDir}/`, '')
702
702
  }
703
- } catch (error: any) {
703
+ } catch (error: unknown) {
704
704
  console.error('[FileService] Error renaming file:', error)
705
705
  return {
706
706
  success: false,
707
- error: error.message || 'Failed to rename'
707
+ error: error instanceof Error ? error.message : 'Failed to rename'
708
708
  }
709
709
  }
710
710
  }
@@ -71,12 +71,12 @@ export function validatePathWithinWorkingDir(
71
71
  valid: true,
72
72
  resolvedPath
73
73
  };
74
- } catch (error: any) {
74
+ } catch (error: unknown) {
75
75
  console.error('[PathUtils] Error validating path:', error);
76
76
  return {
77
77
  valid: false,
78
78
  resolvedPath: '',
79
- error: `Invalid path: ${error.message}`
79
+ error: `Invalid path: ${error instanceof Error ? error.message : String(error)}`
80
80
  };
81
81
  }
82
82
  }
@@ -102,7 +102,18 @@ if (typeof WebSocket !== 'undefined') {
102
102
  WebSocketImpl = WS as unknown as typeof WebSocket
103
103
  }
104
104
 
105
- const DEFAULT_PLATFORM_URL = process.env.PLATFORM_URL || 'https://api.mstro.app'
105
+ // Read SERVER_URL from ~/.mstro/.env if it exists (for local dev)
106
+ function getServerUrl(): string {
107
+ try {
108
+ const envPath = join(MSTRO_DIR, '.env')
109
+ const content = readFileSync(envPath, 'utf-8')
110
+ const match = content.match(/^SERVER_URL=(.+)$/m)
111
+ if (match) return match[1].trim()
112
+ } catch {}
113
+ return 'https://api.mstro.app'
114
+ }
115
+
116
+ const DEFAULT_PLATFORM_URL = process.env.PLATFORM_URL || getServerUrl()
106
117
 
107
118
  interface ConnectionCallbacks {
108
119
  onConnected?: (connectionId: string) => void
@@ -110,7 +121,7 @@ interface ConnectionCallbacks {
110
121
  onError?: (error: string) => void
111
122
  onWebConnected?: () => void
112
123
  onWebDisconnected?: () => void
113
- onRelayedMessage?: (message: any) => void
124
+ onRelayedMessage?: (message: unknown) => void
114
125
  }
115
126
 
116
127
  /**
@@ -340,15 +351,15 @@ export class PlatformConnection {
340
351
  }
341
352
  }
342
353
 
343
- private handleMessage(message: any): void {
354
+ private handleMessage(message: Record<string, unknown>): void {
344
355
  switch (message.type) {
345
356
  case 'paired':
346
357
  this.isConnected = true
347
- this.connectionId = message.connectionId
358
+ this.connectionId = message.connectionId as string
348
359
  // Connection status printed by onConnected callback
349
360
  // Start heartbeat to keep server-side TTL refreshed
350
361
  this.startHeartbeat()
351
- this.callbacks.onConnected?.(message.connectionId)
362
+ this.callbacks.onConnected?.(message.connectionId as string)
352
363
  break
353
364
 
354
365
  case 'web_connected':
@@ -393,7 +404,7 @@ export class PlatformConnection {
393
404
  /**
394
405
  * Send message to platform (will be relayed to web if connected)
395
406
  */
396
- send(message: any): void {
407
+ send(message: unknown): void {
397
408
  if (this.ws && this.ws.readyState === WebSocketImpl.OPEN) {
398
409
  this.ws.send(JSON.stringify(message))
399
410
  }
@@ -65,7 +65,7 @@ export function initSentry(): void {
65
65
  })
66
66
  }
67
67
 
68
- export function captureException(error: unknown, context?: Record<string, any>): void {
68
+ export function captureException(error: unknown, context?: Record<string, unknown>): void {
69
69
  if (!initialized) return
70
70
  Sentry.captureException(error, context ? { extra: context } : undefined)
71
71
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { EventEmitter } from 'node:events';
20
+ import { createRequire } from 'node:module';
20
21
  import { homedir, platform } from 'node:os';
21
22
  import { sanitizeEnvForSandbox } from '../sandbox-utils.js';
22
23
 
@@ -26,8 +27,8 @@ let _ptyLoadError: string | null = null;
26
27
 
27
28
  try {
28
29
  pty = await import('node-pty');
29
- } catch (error: any) {
30
- _ptyLoadError = error.message || 'Failed to load node-pty';
30
+ } catch (error: unknown) {
31
+ _ptyLoadError = error instanceof Error ? error.message : 'Failed to load node-pty';
31
32
  console.warn('[PTYManager] node-pty not available - terminal features disabled');
32
33
  console.warn('[PTYManager] To enable terminals, run: mstro setup-terminal');
33
34
  }
@@ -39,6 +40,32 @@ export function isPtyAvailable(): boolean {
39
40
  return pty !== null;
40
41
  }
41
42
 
43
+ /**
44
+ * Re-attempt loading node-pty at runtime.
45
+ * Called after `mstro setup-terminal` compiles the native module
46
+ * so the running server can pick it up without a restart.
47
+ *
48
+ * Uses createRequire (CJS) to bypass ESM's module cache — a failed
49
+ * ESM import is permanently cached, but CJS require cache entries
50
+ * can be deleted and re-required.
51
+ */
52
+ export async function reloadPty(): Promise<boolean> {
53
+ if (pty) return true;
54
+ try {
55
+ const require = createRequire(import.meta.url);
56
+ // Clear any cached failure so require() retries the native load
57
+ const resolved = require.resolve('node-pty');
58
+ delete require.cache[resolved];
59
+ pty = require('node-pty');
60
+ _ptyLoadError = null;
61
+ console.log('[PTYManager] node-pty loaded successfully after reload');
62
+ return true;
63
+ } catch (error: unknown) {
64
+ _ptyLoadError = error instanceof Error ? error.message : 'Failed to load node-pty';
65
+ return false;
66
+ }
67
+ }
68
+
42
69
  /**
43
70
  * Get installation instructions for node-pty based on platform
44
71
  */
@@ -87,6 +114,9 @@ export interface PTYSession {
87
114
  // Current dimensions
88
115
  cols: number;
89
116
  rows: number;
117
+ // Output coalescing: buffer small chunks into fewer WS messages
118
+ _outputBuffer: string;
119
+ _outputTimer: ReturnType<typeof setTimeout> | null;
90
120
  }
91
121
 
92
122
  /**
@@ -227,25 +257,63 @@ export class PTYManager extends EventEmitter {
227
257
  lastActivityAt: Date.now(),
228
258
  cols,
229
259
  rows,
260
+ _outputBuffer: '',
261
+ _outputTimer: null,
230
262
  };
231
263
  this.terminals.set(terminalId, session);
232
264
 
233
- // Handle data output
265
+ // Handle data output — coalesce small chunks to reduce WebSocket message count.
266
+ // On macOS, node-pty emits many tiny chunks (sometimes single bytes) and zsh
267
+ // wraps echoed chars in multi-part ANSI sequences (RPROMPT, syntax highlighting).
268
+ // A longer window on macOS ensures these multi-part sequences arrive as one chunk,
269
+ // which the browser's predictive echo can match correctly.
270
+ const OUTPUT_COALESCE_MS = platform() === 'darwin' ? 24 : 8;
271
+ // High-water mark: flush immediately when buffer exceeds this size
272
+ // to prevent unbounded memory growth during high-output commands (e.g. `yes`)
273
+ const OUTPUT_HIGH_WATER = 64 * 1024; // 64KB
274
+ // Maximum chunk size per WebSocket message to prevent browser overload
275
+ const OUTPUT_CHUNK_SIZE = 64 * 1024;
276
+
277
+ const flushOutputBuffer = () => {
278
+ if (session._outputTimer) {
279
+ clearTimeout(session._outputTimer);
280
+ session._outputTimer = null;
281
+ }
282
+ const buffered = session._outputBuffer;
283
+ session._outputBuffer = '';
284
+ // Chunk large output to prevent single massive WebSocket frames
285
+ for (let i = 0; i < buffered.length; i += OUTPUT_CHUNK_SIZE) {
286
+ this.emit('output', terminalId, buffered.slice(i, i + OUTPUT_CHUNK_SIZE));
287
+ }
288
+ };
289
+
234
290
  ptyProcess.onData((data: string) => {
235
291
  session.lastActivityAt = Date.now();
236
- this.emit('output', terminalId, data);
292
+ session._outputBuffer += data;
293
+ // Flush immediately if buffer exceeds high-water mark
294
+ if (session._outputBuffer.length >= OUTPUT_HIGH_WATER) {
295
+ flushOutputBuffer();
296
+ } else if (!session._outputTimer) {
297
+ session._outputTimer = setTimeout(flushOutputBuffer, OUTPUT_COALESCE_MS);
298
+ }
237
299
  });
238
300
 
239
- // Handle exit
301
+ // Handle exit — flush any buffered output first
240
302
  ptyProcess.onExit(({ exitCode }) => {
303
+ if (session._outputBuffer) {
304
+ flushOutputBuffer();
305
+ } else if (session._outputTimer) {
306
+ clearTimeout(session._outputTimer);
307
+ session._outputTimer = null;
308
+ }
241
309
  this.emit('exit', terminalId, exitCode);
242
310
  this.terminals.delete(terminalId);
243
311
  });
244
312
 
245
313
  return { shell: session.shell, cwd, isReconnect: false };
246
- } catch (error: any) {
314
+ } catch (error: unknown) {
247
315
  console.error(`[PTYManager] Failed to create terminal ${terminalId}:`, error);
248
- this.emit('error', terminalId, error.message || 'Failed to create terminal');
316
+ this.emit('error', terminalId, error instanceof Error ? error.message : 'Failed to create terminal');
249
317
  throw error;
250
318
  }
251
319
  }
@@ -263,9 +331,9 @@ export class PTYManager extends EventEmitter {
263
331
  try {
264
332
  session.pty.write(data);
265
333
  return true;
266
- } catch (error: any) {
334
+ } catch (error: unknown) {
267
335
  console.error(`[PTYManager] Error writing to terminal ${terminalId}:`, error);
268
- this.emit('error', terminalId, error.message || 'Write failed');
336
+ this.emit('error', terminalId, error instanceof Error ? error.message : 'Write failed');
269
337
  return false;
270
338
  }
271
339
  }
@@ -283,7 +351,7 @@ export class PTYManager extends EventEmitter {
283
351
  try {
284
352
  session.pty.resize(cols, rows);
285
353
  return true;
286
- } catch (error: any) {
354
+ } catch (error: unknown) {
287
355
  console.error(`[PTYManager] Error resizing terminal ${terminalId}:`, error);
288
356
  return false;
289
357
  }
@@ -300,10 +368,19 @@ export class PTYManager extends EventEmitter {
300
368
 
301
369
 
302
370
  try {
371
+ // Flush any coalesced output before closing
372
+ if (session._outputTimer) {
373
+ clearTimeout(session._outputTimer);
374
+ if (session._outputBuffer) {
375
+ this.emit('output', terminalId, session._outputBuffer);
376
+ session._outputBuffer = '';
377
+ }
378
+ session._outputTimer = null;
379
+ }
303
380
  session.pty.kill();
304
381
  this.terminals.delete(terminalId);
305
382
  return true;
306
- } catch (error: any) {
383
+ } catch (error: unknown) {
307
384
  console.error(`[PTYManager] Error closing terminal ${terminalId}:`, error);
308
385
  this.terminals.delete(terminalId);
309
386
  return false;