mstro-app 0.1.47

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/bin/commands/config.js +145 -0
  4. package/bin/commands/login.js +313 -0
  5. package/bin/commands/logout.js +75 -0
  6. package/bin/commands/status.js +197 -0
  7. package/bin/commands/whoami.js +161 -0
  8. package/bin/configure-claude.js +298 -0
  9. package/bin/mstro.js +581 -0
  10. package/bin/postinstall.js +45 -0
  11. package/bin/release.sh +110 -0
  12. package/dist/server/cli/headless/claude-invoker.d.ts +17 -0
  13. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -0
  14. package/dist/server/cli/headless/claude-invoker.js +311 -0
  15. package/dist/server/cli/headless/claude-invoker.js.map +1 -0
  16. package/dist/server/cli/headless/index.d.ts +13 -0
  17. package/dist/server/cli/headless/index.d.ts.map +1 -0
  18. package/dist/server/cli/headless/index.js +10 -0
  19. package/dist/server/cli/headless/index.js.map +1 -0
  20. package/dist/server/cli/headless/mcp-config.d.ts +11 -0
  21. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -0
  22. package/dist/server/cli/headless/mcp-config.js +76 -0
  23. package/dist/server/cli/headless/mcp-config.js.map +1 -0
  24. package/dist/server/cli/headless/output-utils.d.ts +33 -0
  25. package/dist/server/cli/headless/output-utils.d.ts.map +1 -0
  26. package/dist/server/cli/headless/output-utils.js +101 -0
  27. package/dist/server/cli/headless/output-utils.js.map +1 -0
  28. package/dist/server/cli/headless/prompt-utils.d.ts +21 -0
  29. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -0
  30. package/dist/server/cli/headless/prompt-utils.js +84 -0
  31. package/dist/server/cli/headless/prompt-utils.js.map +1 -0
  32. package/dist/server/cli/headless/runner.d.ts +24 -0
  33. package/dist/server/cli/headless/runner.d.ts.map +1 -0
  34. package/dist/server/cli/headless/runner.js +99 -0
  35. package/dist/server/cli/headless/runner.js.map +1 -0
  36. package/dist/server/cli/headless/types.d.ts +106 -0
  37. package/dist/server/cli/headless/types.d.ts.map +1 -0
  38. package/dist/server/cli/headless/types.js +4 -0
  39. package/dist/server/cli/headless/types.js.map +1 -0
  40. package/dist/server/cli/improvisation-session-manager.d.ts +155 -0
  41. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -0
  42. package/dist/server/cli/improvisation-session-manager.js +415 -0
  43. package/dist/server/cli/improvisation-session-manager.js.map +1 -0
  44. package/dist/server/index.d.ts +2 -0
  45. package/dist/server/index.d.ts.map +1 -0
  46. package/dist/server/index.js +386 -0
  47. package/dist/server/index.js.map +1 -0
  48. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  49. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  50. package/dist/server/mcp/bouncer-cli.js +99 -0
  51. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  52. package/dist/server/mcp/bouncer-integration.d.ts +36 -0
  53. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -0
  54. package/dist/server/mcp/bouncer-integration.js +301 -0
  55. package/dist/server/mcp/bouncer-integration.js.map +1 -0
  56. package/dist/server/mcp/security-audit.d.ts +52 -0
  57. package/dist/server/mcp/security-audit.d.ts.map +1 -0
  58. package/dist/server/mcp/security-audit.js +118 -0
  59. package/dist/server/mcp/security-audit.js.map +1 -0
  60. package/dist/server/mcp/security-patterns.d.ts +73 -0
  61. package/dist/server/mcp/security-patterns.d.ts.map +1 -0
  62. package/dist/server/mcp/security-patterns.js +247 -0
  63. package/dist/server/mcp/security-patterns.js.map +1 -0
  64. package/dist/server/mcp/server.d.ts +3 -0
  65. package/dist/server/mcp/server.d.ts.map +1 -0
  66. package/dist/server/mcp/server.js +146 -0
  67. package/dist/server/mcp/server.js.map +1 -0
  68. package/dist/server/routes/files.d.ts +9 -0
  69. package/dist/server/routes/files.d.ts.map +1 -0
  70. package/dist/server/routes/files.js +24 -0
  71. package/dist/server/routes/files.js.map +1 -0
  72. package/dist/server/routes/improvise.d.ts +3 -0
  73. package/dist/server/routes/improvise.d.ts.map +1 -0
  74. package/dist/server/routes/improvise.js +72 -0
  75. package/dist/server/routes/improvise.js.map +1 -0
  76. package/dist/server/routes/index.d.ts +10 -0
  77. package/dist/server/routes/index.d.ts.map +1 -0
  78. package/dist/server/routes/index.js +12 -0
  79. package/dist/server/routes/index.js.map +1 -0
  80. package/dist/server/routes/instances.d.ts +10 -0
  81. package/dist/server/routes/instances.d.ts.map +1 -0
  82. package/dist/server/routes/instances.js +47 -0
  83. package/dist/server/routes/instances.js.map +1 -0
  84. package/dist/server/routes/notifications.d.ts +3 -0
  85. package/dist/server/routes/notifications.d.ts.map +1 -0
  86. package/dist/server/routes/notifications.js +136 -0
  87. package/dist/server/routes/notifications.js.map +1 -0
  88. package/dist/server/services/analytics.d.ts +56 -0
  89. package/dist/server/services/analytics.d.ts.map +1 -0
  90. package/dist/server/services/analytics.js +240 -0
  91. package/dist/server/services/analytics.js.map +1 -0
  92. package/dist/server/services/auth.d.ts +26 -0
  93. package/dist/server/services/auth.d.ts.map +1 -0
  94. package/dist/server/services/auth.js +71 -0
  95. package/dist/server/services/auth.js.map +1 -0
  96. package/dist/server/services/client-id.d.ts +10 -0
  97. package/dist/server/services/client-id.d.ts.map +1 -0
  98. package/dist/server/services/client-id.js +61 -0
  99. package/dist/server/services/client-id.js.map +1 -0
  100. package/dist/server/services/credentials.d.ts +39 -0
  101. package/dist/server/services/credentials.d.ts.map +1 -0
  102. package/dist/server/services/credentials.js +110 -0
  103. package/dist/server/services/credentials.js.map +1 -0
  104. package/dist/server/services/files.d.ts +119 -0
  105. package/dist/server/services/files.d.ts.map +1 -0
  106. package/dist/server/services/files.js +560 -0
  107. package/dist/server/services/files.js.map +1 -0
  108. package/dist/server/services/instances.d.ts +52 -0
  109. package/dist/server/services/instances.d.ts.map +1 -0
  110. package/dist/server/services/instances.js +241 -0
  111. package/dist/server/services/instances.js.map +1 -0
  112. package/dist/server/services/pathUtils.d.ts +47 -0
  113. package/dist/server/services/pathUtils.d.ts.map +1 -0
  114. package/dist/server/services/pathUtils.js +124 -0
  115. package/dist/server/services/pathUtils.js.map +1 -0
  116. package/dist/server/services/platform.d.ts +72 -0
  117. package/dist/server/services/platform.d.ts.map +1 -0
  118. package/dist/server/services/platform.js +368 -0
  119. package/dist/server/services/platform.js.map +1 -0
  120. package/dist/server/services/sentry.d.ts +5 -0
  121. package/dist/server/services/sentry.d.ts.map +1 -0
  122. package/dist/server/services/sentry.js +71 -0
  123. package/dist/server/services/sentry.js.map +1 -0
  124. package/dist/server/services/terminal/pty-manager.d.ts +149 -0
  125. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -0
  126. package/dist/server/services/terminal/pty-manager.js +377 -0
  127. package/dist/server/services/terminal/pty-manager.js.map +1 -0
  128. package/dist/server/services/terminal/tmux-manager.d.ts +82 -0
  129. package/dist/server/services/terminal/tmux-manager.d.ts.map +1 -0
  130. package/dist/server/services/terminal/tmux-manager.js +352 -0
  131. package/dist/server/services/terminal/tmux-manager.js.map +1 -0
  132. package/dist/server/services/websocket/autocomplete.d.ts +50 -0
  133. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -0
  134. package/dist/server/services/websocket/autocomplete.js +361 -0
  135. package/dist/server/services/websocket/autocomplete.js.map +1 -0
  136. package/dist/server/services/websocket/file-utils.d.ts +44 -0
  137. package/dist/server/services/websocket/file-utils.d.ts.map +1 -0
  138. package/dist/server/services/websocket/file-utils.js +272 -0
  139. package/dist/server/services/websocket/file-utils.js.map +1 -0
  140. package/dist/server/services/websocket/handler.d.ts +246 -0
  141. package/dist/server/services/websocket/handler.d.ts.map +1 -0
  142. package/dist/server/services/websocket/handler.js +1771 -0
  143. package/dist/server/services/websocket/handler.js.map +1 -0
  144. package/dist/server/services/websocket/index.d.ts +11 -0
  145. package/dist/server/services/websocket/index.d.ts.map +1 -0
  146. package/dist/server/services/websocket/index.js +14 -0
  147. package/dist/server/services/websocket/index.js.map +1 -0
  148. package/dist/server/services/websocket/types.d.ts +214 -0
  149. package/dist/server/services/websocket/types.d.ts.map +1 -0
  150. package/dist/server/services/websocket/types.js +4 -0
  151. package/dist/server/services/websocket/types.js.map +1 -0
  152. package/dist/server/utils/agent-manager.d.ts +69 -0
  153. package/dist/server/utils/agent-manager.d.ts.map +1 -0
  154. package/dist/server/utils/agent-manager.js +269 -0
  155. package/dist/server/utils/agent-manager.js.map +1 -0
  156. package/dist/server/utils/paths.d.ts +25 -0
  157. package/dist/server/utils/paths.d.ts.map +1 -0
  158. package/dist/server/utils/paths.js +38 -0
  159. package/dist/server/utils/paths.js.map +1 -0
  160. package/dist/server/utils/port-manager.d.ts +10 -0
  161. package/dist/server/utils/port-manager.d.ts.map +1 -0
  162. package/dist/server/utils/port-manager.js +60 -0
  163. package/dist/server/utils/port-manager.js.map +1 -0
  164. package/dist/server/utils/port.d.ts +26 -0
  165. package/dist/server/utils/port.d.ts.map +1 -0
  166. package/dist/server/utils/port.js +83 -0
  167. package/dist/server/utils/port.js.map +1 -0
  168. package/hooks/bouncer.sh +138 -0
  169. package/package.json +74 -0
  170. package/server/README.md +191 -0
  171. package/server/cli/headless/claude-invoker.ts +415 -0
  172. package/server/cli/headless/index.ts +39 -0
  173. package/server/cli/headless/mcp-config.ts +87 -0
  174. package/server/cli/headless/output-utils.ts +109 -0
  175. package/server/cli/headless/prompt-utils.ts +108 -0
  176. package/server/cli/headless/runner.ts +133 -0
  177. package/server/cli/headless/types.ts +118 -0
  178. package/server/cli/improvisation-session-manager.ts +531 -0
  179. package/server/index.ts +456 -0
  180. package/server/mcp/README.md +122 -0
  181. package/server/mcp/bouncer-cli.ts +127 -0
  182. package/server/mcp/bouncer-integration.ts +430 -0
  183. package/server/mcp/security-audit.ts +180 -0
  184. package/server/mcp/security-patterns.ts +290 -0
  185. package/server/mcp/server.ts +174 -0
  186. package/server/routes/files.ts +29 -0
  187. package/server/routes/improvise.ts +82 -0
  188. package/server/routes/index.ts +13 -0
  189. package/server/routes/instances.ts +54 -0
  190. package/server/routes/notifications.ts +158 -0
  191. package/server/services/analytics.ts +277 -0
  192. package/server/services/auth.ts +80 -0
  193. package/server/services/client-id.ts +68 -0
  194. package/server/services/credentials.ts +134 -0
  195. package/server/services/files.ts +710 -0
  196. package/server/services/instances.ts +275 -0
  197. package/server/services/pathUtils.ts +158 -0
  198. package/server/services/platform.test.ts +1314 -0
  199. package/server/services/platform.ts +435 -0
  200. package/server/services/sentry.ts +81 -0
  201. package/server/services/terminal/pty-manager.ts +464 -0
  202. package/server/services/terminal/tmux-manager.ts +426 -0
  203. package/server/services/websocket/autocomplete.ts +438 -0
  204. package/server/services/websocket/file-utils.ts +305 -0
  205. package/server/services/websocket/handler.test.ts +20 -0
  206. package/server/services/websocket/handler.ts +2047 -0
  207. package/server/services/websocket/index.ts +40 -0
  208. package/server/services/websocket/types.ts +339 -0
  209. package/server/tsconfig.json +19 -0
  210. package/server/utils/agent-manager.ts +323 -0
  211. package/server/utils/paths.ts +45 -0
  212. package/server/utils/port-manager.ts +70 -0
  213. package/server/utils/port.ts +102 -0
@@ -0,0 +1,1771 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+ /**
4
+ * WebSocket Handler for Improvisation Sessions
5
+ *
6
+ * Manages WebSocket connections for real-time improvisation sessions.
7
+ * Integrates with ImprovisationSessionManager to execute Claude Code commands.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
14
+ import { createDirectory, createFile, deleteFile, listDirectory, renameFile, writeFile } from '../files.js';
15
+ import { captureException } from '../sentry.js';
16
+ import { getPTYManager } from '../terminal/pty-manager.js';
17
+ import { AutocompleteService } from './autocomplete.js';
18
+ import { readFileContent } from './file-utils.js';
19
+ export class WebSocketImproviseHandler {
20
+ sessions = new Map();
21
+ connections = new Map();
22
+ autocompleteService;
23
+ frecencyPath;
24
+ usageReporter = null;
25
+ /** Per-tab selected git directory (tabId -> directory path) */
26
+ gitDirectories = new Map();
27
+ constructor() {
28
+ this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
29
+ const frecencyData = this.loadFrecencyData();
30
+ this.autocompleteService = new AutocompleteService(frecencyData);
31
+ }
32
+ /**
33
+ * Set the usage reporter callback for sending usage data to platform
34
+ */
35
+ setUsageReporter(reporter) {
36
+ this.usageReporter = reporter;
37
+ }
38
+ /**
39
+ * Load frecency data from disk
40
+ */
41
+ loadFrecencyData() {
42
+ try {
43
+ if (existsSync(this.frecencyPath)) {
44
+ const data = readFileSync(this.frecencyPath, 'utf-8');
45
+ return JSON.parse(data);
46
+ }
47
+ }
48
+ catch (error) {
49
+ console.error('[WebSocketImproviseHandler] Error loading frecency data:', error);
50
+ }
51
+ return {};
52
+ }
53
+ /**
54
+ * Save frecency data to disk
55
+ */
56
+ saveFrecencyData() {
57
+ try {
58
+ const dir = dirname(this.frecencyPath);
59
+ if (!existsSync(dir)) {
60
+ mkdirSync(dir, { recursive: true });
61
+ }
62
+ writeFileSync(this.frecencyPath, JSON.stringify(this.autocompleteService.getFrecencyData(), null, 2));
63
+ }
64
+ catch (error) {
65
+ console.error('[WebSocketImproviseHandler] Error saving frecency data:', error);
66
+ }
67
+ }
68
+ /**
69
+ * Record a file selection for frecency scoring
70
+ */
71
+ recordFileSelection(filePath) {
72
+ this.autocompleteService.recordFileSelection(filePath);
73
+ this.saveFrecencyData();
74
+ }
75
+ /**
76
+ * Handle new WebSocket connection
77
+ */
78
+ handleConnection(ws, _workingDir) {
79
+ this.connections.set(ws, new Map());
80
+ }
81
+ /**
82
+ * Handle incoming WebSocket message
83
+ */
84
+ async handleMessage(ws, message, workingDir) {
85
+ try {
86
+ const msg = JSON.parse(message);
87
+ const tabId = msg.tabId || 'default';
88
+ await this.dispatchMessage(ws, msg, tabId, workingDir);
89
+ }
90
+ catch (error) {
91
+ console.error('[WebSocketImproviseHandler] Error handling message:', error);
92
+ captureException(error, { context: 'websocket.handleMessage' });
93
+ this.send(ws, {
94
+ type: 'error',
95
+ data: { message: error.message }
96
+ });
97
+ }
98
+ }
99
+ /**
100
+ * Dispatch a parsed message to the appropriate handler
101
+ */
102
+ async dispatchMessage(ws, msg, tabId, workingDir) {
103
+ switch (msg.type) {
104
+ case 'ping':
105
+ this.send(ws, { type: 'pong', tabId });
106
+ return;
107
+ case 'initTab':
108
+ return void await this.initializeTab(ws, tabId, workingDir);
109
+ case 'resumeSession':
110
+ if (!msg.data?.historicalSessionId)
111
+ throw new Error('Historical session ID is required');
112
+ return void await this.resumeHistoricalSession(ws, tabId, workingDir, msg.data.historicalSessionId);
113
+ case 'execute':
114
+ case 'cancel':
115
+ case 'getHistory':
116
+ case 'new':
117
+ case 'approve':
118
+ case 'reject':
119
+ return this.handleSessionMessage(ws, msg, tabId);
120
+ case 'getSessions':
121
+ case 'getSessionsCount':
122
+ case 'getSessionById':
123
+ case 'deleteSession':
124
+ case 'clearHistory':
125
+ case 'searchHistory':
126
+ return this.handleHistoryMessage(ws, msg, tabId, workingDir);
127
+ case 'autocomplete':
128
+ case 'readFile':
129
+ case 'recordSelection':
130
+ case 'requestNotificationSummary':
131
+ return this.handleFileMessage(ws, msg, tabId, workingDir);
132
+ case 'terminalInit':
133
+ case 'terminalReconnect':
134
+ case 'terminalList':
135
+ case 'terminalInitPersistent':
136
+ case 'terminalListPersistent':
137
+ case 'terminalInput':
138
+ case 'terminalResize':
139
+ case 'terminalClose':
140
+ return this.handleTerminalMessage(ws, msg, tabId, workingDir);
141
+ case 'listDirectory':
142
+ case 'writeFile':
143
+ case 'createFile':
144
+ case 'createDirectory':
145
+ case 'deleteFile':
146
+ case 'renameFile':
147
+ return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
148
+ case 'gitStatus':
149
+ case 'gitStage':
150
+ case 'gitUnstage':
151
+ case 'gitCommit':
152
+ case 'gitCommitWithAI':
153
+ case 'gitPush':
154
+ case 'gitLog':
155
+ case 'gitDiscoverRepos':
156
+ case 'gitSetDirectory':
157
+ return this.handleGitMessage(ws, msg, tabId, workingDir);
158
+ default:
159
+ throw new Error(`Unknown message type: ${msg.type}`);
160
+ }
161
+ }
162
+ /**
163
+ * Handle session-related messages (execute, cancel, history, new, approve, reject)
164
+ */
165
+ handleSessionMessage(ws, msg, tabId) {
166
+ switch (msg.type) {
167
+ case 'execute': {
168
+ if (!msg.data?.prompt)
169
+ throw new Error('Prompt is required');
170
+ const session = this.requireSession(ws, tabId);
171
+ session.executePrompt(msg.data.prompt, msg.data.attachments);
172
+ break;
173
+ }
174
+ case 'cancel': {
175
+ const session = this.requireSession(ws, tabId);
176
+ session.cancel();
177
+ this.send(ws, { type: 'output', tabId, data: { text: '\n⚠️ Operation cancelled\n' } });
178
+ break;
179
+ }
180
+ case 'getHistory': {
181
+ const session = this.requireSession(ws, tabId);
182
+ this.send(ws, { type: 'history', tabId, data: session.getHistory() });
183
+ break;
184
+ }
185
+ case 'new': {
186
+ const oldSession = this.requireSession(ws, tabId);
187
+ const newSession = oldSession.startNewSession();
188
+ this.setupSessionListeners(newSession, ws, tabId);
189
+ const newSessionId = newSession.getSessionInfo().sessionId;
190
+ this.sessions.set(newSessionId, newSession);
191
+ const tabMap = this.connections.get(ws);
192
+ if (tabMap)
193
+ tabMap.set(tabId, newSessionId);
194
+ this.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
195
+ break;
196
+ }
197
+ case 'approve': {
198
+ const session = this.requireSession(ws, tabId);
199
+ session.respondToApproval?.(true);
200
+ this.send(ws, { type: 'output', tabId, data: { text: '\n✅ Approved - proceeding with operation\n' } });
201
+ break;
202
+ }
203
+ case 'reject': {
204
+ const session = this.requireSession(ws, tabId);
205
+ session.respondToApproval?.(false);
206
+ this.send(ws, { type: 'output', tabId, data: { text: '\n🚫 Rejected - operation cancelled\n' } });
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ /**
212
+ * Handle history/session listing messages
213
+ */
214
+ handleHistoryMessage(ws, msg, tabId, workingDir) {
215
+ switch (msg.type) {
216
+ case 'getSessions': {
217
+ const result = this.getSessionsList(workingDir, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
218
+ this.send(ws, { type: 'sessions', tabId, data: result });
219
+ break;
220
+ }
221
+ case 'getSessionsCount':
222
+ this.send(ws, { type: 'sessionsCount', tabId, data: { total: this.getSessionsCount(workingDir) } });
223
+ break;
224
+ case 'getSessionById':
225
+ if (!msg.data?.sessionId)
226
+ throw new Error('Session ID is required');
227
+ this.send(ws, { type: 'sessionData', tabId, data: this.getSessionById(workingDir, msg.data.sessionId) });
228
+ break;
229
+ case 'deleteSession':
230
+ if (!msg.data?.sessionId)
231
+ throw new Error('Session ID is required');
232
+ this.send(ws, { type: 'sessionDeleted', tabId, data: this.deleteSession(workingDir, msg.data.sessionId) });
233
+ break;
234
+ case 'clearHistory':
235
+ this.send(ws, { type: 'historyCleared', tabId, data: this.clearAllSessions(workingDir) });
236
+ break;
237
+ case 'searchHistory': {
238
+ if (!msg.data?.query)
239
+ throw new Error('Search query is required');
240
+ const result = this.searchSessions(workingDir, msg.data.query, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
241
+ this.send(ws, { type: 'searchResults', tabId, data: { ...result, query: msg.data.query } });
242
+ break;
243
+ }
244
+ }
245
+ }
246
+ /**
247
+ * Handle file-related messages (autocomplete, readFile, recordSelection, notifications)
248
+ */
249
+ handleFileMessage(ws, msg, tabId, workingDir) {
250
+ switch (msg.type) {
251
+ case 'autocomplete':
252
+ if (!msg.data?.partialPath)
253
+ throw new Error('Partial path is required');
254
+ this.send(ws, { type: 'autocomplete', tabId, data: { completions: this.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
255
+ break;
256
+ case 'readFile':
257
+ if (!msg.data?.filePath)
258
+ throw new Error('File path is required');
259
+ this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
260
+ break;
261
+ case 'recordSelection':
262
+ if (msg.data?.filePath)
263
+ this.recordFileSelection(msg.data.filePath);
264
+ break;
265
+ case 'requestNotificationSummary':
266
+ if (!msg.data?.prompt || !msg.data?.output)
267
+ throw new Error('Prompt and output are required for notification summary');
268
+ this.generateNotificationSummary(ws, tabId, msg.data.prompt, msg.data.output, workingDir);
269
+ break;
270
+ }
271
+ }
272
+ /**
273
+ * Handle terminal messages
274
+ */
275
+ handleTerminalMessage(ws, msg, tabId, workingDir) {
276
+ const termId = msg.terminalId || tabId;
277
+ switch (msg.type) {
278
+ case 'terminalInit':
279
+ this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
280
+ break;
281
+ case 'terminalReconnect':
282
+ this.handleTerminalReconnect(ws, termId);
283
+ break;
284
+ case 'terminalList':
285
+ this.handleTerminalList(ws);
286
+ break;
287
+ case 'terminalInitPersistent':
288
+ this.handleTerminalInitPersistent(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
289
+ break;
290
+ case 'terminalListPersistent':
291
+ this.handleTerminalListPersistent(ws);
292
+ break;
293
+ case 'terminalInput':
294
+ this.handleTerminalInput(ws, termId, msg.data?.input);
295
+ break;
296
+ case 'terminalResize':
297
+ this.handleTerminalResize(ws, termId, msg.data?.cols, msg.data?.rows);
298
+ break;
299
+ case 'terminalClose':
300
+ this.handleTerminalClose(ws, termId);
301
+ break;
302
+ }
303
+ }
304
+ /**
305
+ * Handle file explorer operations with success/error response pattern
306
+ */
307
+ sendFileResult(ws, type, tabId, result, successData) {
308
+ const data = result.success
309
+ ? { success: true, path: result.path, ...successData }
310
+ : { success: false, path: result.path, error: result.error };
311
+ this.send(ws, { type, tabId, data });
312
+ }
313
+ handleListDirectory(ws, msg, tabId, workingDir) {
314
+ if (msg.data?.dirPath === undefined)
315
+ throw new Error('Directory path is required');
316
+ const result = listDirectory(msg.data.dirPath, workingDir, msg.data.showHidden ?? false);
317
+ this.send(ws, { type: 'directoryListing', tabId, data: result.success ? { success: true, path: msg.data.dirPath, entries: result.entries } : { success: false, path: msg.data.dirPath, error: result.error } });
318
+ }
319
+ handleWriteFile(ws, msg, tabId, workingDir) {
320
+ if (!msg.data?.filePath)
321
+ throw new Error('File path is required');
322
+ if (msg.data.content === undefined)
323
+ throw new Error('Content is required');
324
+ this.sendFileResult(ws, 'fileWritten', tabId, writeFile(msg.data.filePath, msg.data.content, workingDir));
325
+ }
326
+ handleCreateFile(ws, msg, tabId, workingDir) {
327
+ if (!msg.data?.filePath)
328
+ throw new Error('File path is required');
329
+ this.sendFileResult(ws, 'fileCreated', tabId, createFile(msg.data.filePath, workingDir));
330
+ }
331
+ handleCreateDirectory(ws, msg, tabId, workingDir) {
332
+ if (!msg.data?.dirPath)
333
+ throw new Error('Directory path is required');
334
+ this.sendFileResult(ws, 'directoryCreated', tabId, createDirectory(msg.data.dirPath, workingDir));
335
+ }
336
+ handleDeleteFile(ws, msg, tabId, workingDir) {
337
+ if (!msg.data?.filePath)
338
+ throw new Error('File path is required');
339
+ this.sendFileResult(ws, 'fileDeleted', tabId, deleteFile(msg.data.filePath, workingDir));
340
+ }
341
+ handleRenameFile(ws, msg, tabId, workingDir) {
342
+ if (!msg.data?.oldPath)
343
+ throw new Error('Old path is required');
344
+ if (!msg.data?.newPath)
345
+ throw new Error('New path is required');
346
+ this.sendFileResult(ws, 'fileRenamed', tabId, renameFile(msg.data.oldPath, msg.data.newPath, workingDir));
347
+ }
348
+ handleFileExplorerMessage(ws, msg, tabId, workingDir) {
349
+ const handlers = {
350
+ listDirectory: () => this.handleListDirectory(ws, msg, tabId, workingDir),
351
+ writeFile: () => this.handleWriteFile(ws, msg, tabId, workingDir),
352
+ createFile: () => this.handleCreateFile(ws, msg, tabId, workingDir),
353
+ createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
354
+ deleteFile: () => this.handleDeleteFile(ws, msg, tabId, workingDir),
355
+ renameFile: () => this.handleRenameFile(ws, msg, tabId, workingDir),
356
+ };
357
+ handlers[msg.type]?.();
358
+ }
359
+ /**
360
+ * Get a session or throw
361
+ */
362
+ requireSession(ws, tabId) {
363
+ const session = this.getSession(ws, tabId);
364
+ if (!session)
365
+ throw new Error(`No session found for tab ${tabId}`);
366
+ return session;
367
+ }
368
+ /**
369
+ * Set up event listeners for a session
370
+ */
371
+ setupSessionListeners(session, ws, tabId) {
372
+ // Remove any existing listeners to prevent duplicates on reattach/reconnect
373
+ session.removeAllListeners();
374
+ session.on('onOutput', (text) => {
375
+ this.send(ws, { type: 'output', tabId, data: { text, timestamp: Date.now() } });
376
+ });
377
+ session.on('onThinking', (text) => {
378
+ this.send(ws, { type: 'thinking', tabId, data: { text } });
379
+ });
380
+ session.on('onMovementStart', (sequenceNumber, prompt) => {
381
+ this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
382
+ });
383
+ session.on('onMovementComplete', (movement) => {
384
+ this.send(ws, { type: 'movementComplete', tabId, data: movement });
385
+ // Report usage to platform if reporter is configured
386
+ if (this.usageReporter && movement.tokensUsed) {
387
+ this.usageReporter({
388
+ tokensUsed: movement.tokensUsed,
389
+ sessionId: session.getSessionInfo().sessionId,
390
+ movementId: `${movement.sequenceNumber}`
391
+ });
392
+ }
393
+ });
394
+ session.on('onMovementError', (error) => {
395
+ this.send(ws, { type: 'movementError', tabId, data: { message: error.message } });
396
+ });
397
+ session.on('onSessionUpdate', (history) => {
398
+ this.send(ws, { type: 'sessionUpdate', tabId, data: history });
399
+ });
400
+ session.on('onPlanNeedsConfirmation', (plan) => {
401
+ this.send(ws, { type: 'approvalRequired', tabId, data: plan });
402
+ });
403
+ session.on('onToolUse', (event) => {
404
+ this.send(ws, { type: 'toolUse', tabId, data: { ...event, timestamp: Date.now() } });
405
+ });
406
+ }
407
+ /**
408
+ * Resume a historical session for conversation continuity
409
+ * Falls back to creating a new session if the historical session cannot be found
410
+ * (e.g., server restarted before the session was saved to disk)
411
+ */
412
+ async resumeHistoricalSession(ws, tabId, workingDir, historicalSessionId) {
413
+ const tabMap = this.connections.get(ws);
414
+ const existingSessionId = tabMap?.get(tabId);
415
+ if (existingSessionId) {
416
+ const existingSession = this.sessions.get(existingSessionId);
417
+ if (existingSession) {
418
+ this.setupSessionListeners(existingSession, ws, tabId);
419
+ this.send(ws, {
420
+ type: 'tabInitialized',
421
+ tabId,
422
+ data: existingSession.getSessionInfo()
423
+ });
424
+ return;
425
+ }
426
+ }
427
+ let session;
428
+ let isNewSession = false;
429
+ try {
430
+ session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId);
431
+ }
432
+ catch (error) {
433
+ // Historical session not found on disk - this can happen if the server
434
+ // restarted before any prompts were executed (history is only saved after
435
+ // the first prompt). Fall back to creating a fresh session.
436
+ console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
437
+ session = new ImprovisationSessionManager({ workingDir });
438
+ isNewSession = true;
439
+ }
440
+ this.setupSessionListeners(session, ws, tabId);
441
+ const sessionId = session.getSessionInfo().sessionId;
442
+ this.sessions.set(sessionId, session);
443
+ if (tabMap) {
444
+ tabMap.set(tabId, sessionId);
445
+ }
446
+ this.send(ws, {
447
+ type: 'tabInitialized',
448
+ tabId,
449
+ data: {
450
+ ...session.getSessionInfo(),
451
+ // Let the client know if we had to create a new session instead of resuming
452
+ resumeFailed: isNewSession,
453
+ originalSessionId: isNewSession ? historicalSessionId : undefined
454
+ }
455
+ });
456
+ }
457
+ /**
458
+ * Initialize a new tab with its own session
459
+ */
460
+ async initializeTab(ws, tabId, workingDir) {
461
+ const tabMap = this.connections.get(ws);
462
+ const existingSessionId = tabMap?.get(tabId);
463
+ if (existingSessionId) {
464
+ const existingSession = this.sessions.get(existingSessionId);
465
+ if (existingSession) {
466
+ this.setupSessionListeners(existingSession, ws, tabId);
467
+ this.send(ws, {
468
+ type: 'tabInitialized',
469
+ tabId,
470
+ data: existingSession.getSessionInfo()
471
+ });
472
+ return;
473
+ }
474
+ }
475
+ const session = new ImprovisationSessionManager({ workingDir });
476
+ this.setupSessionListeners(session, ws, tabId);
477
+ const sessionId = session.getSessionInfo().sessionId;
478
+ this.sessions.set(sessionId, session);
479
+ if (tabMap) {
480
+ tabMap.set(tabId, sessionId);
481
+ }
482
+ this.send(ws, {
483
+ type: 'tabInitialized',
484
+ tabId,
485
+ data: session.getSessionInfo()
486
+ });
487
+ }
488
+ /**
489
+ * Get session for a specific tab
490
+ */
491
+ getSession(ws, tabId) {
492
+ const tabMap = this.connections.get(ws);
493
+ if (!tabMap)
494
+ return null;
495
+ const sessionId = tabMap.get(tabId);
496
+ if (!sessionId)
497
+ return null;
498
+ return this.sessions.get(sessionId) || null;
499
+ }
500
+ /**
501
+ * Handle connection close
502
+ */
503
+ handleClose(ws) {
504
+ this.connections.delete(ws);
505
+ }
506
+ /**
507
+ * Send message to WebSocket client
508
+ */
509
+ send(ws, response) {
510
+ try {
511
+ ws.send(JSON.stringify(response));
512
+ }
513
+ catch (error) {
514
+ console.error('[WebSocketImproviseHandler] Error sending message:', error);
515
+ }
516
+ }
517
+ /**
518
+ * Get count of all historical sessions without reading file contents
519
+ */
520
+ getSessionsCount(workingDir) {
521
+ const sessionsDir = join(workingDir, '.mstro', 'improvise');
522
+ if (!existsSync(sessionsDir)) {
523
+ return 0;
524
+ }
525
+ return readdirSync(sessionsDir)
526
+ .filter((name) => name.startsWith('history-') && name.endsWith('.json'))
527
+ .length;
528
+ }
529
+ /**
530
+ * Get paginated list of historical sessions from disk
531
+ * Returns minimal metadata - movements are stripped to just userPrompt preview
532
+ */
533
+ getSessionsList(workingDir, limit = 20, offset = 0) {
534
+ const sessionsDir = join(workingDir, '.mstro', 'improvise');
535
+ if (!existsSync(sessionsDir)) {
536
+ return { sessions: [], total: 0, hasMore: false };
537
+ }
538
+ // Get sorted file list (newest first) without reading contents
539
+ const historyFiles = readdirSync(sessionsDir)
540
+ .filter((name) => name.startsWith('history-') && name.endsWith('.json'))
541
+ .sort((a, b) => {
542
+ const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
543
+ const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
544
+ return timestampB - timestampA;
545
+ });
546
+ const total = historyFiles.length;
547
+ // Only read the files we need for this page
548
+ const pageFiles = historyFiles.slice(offset, offset + limit);
549
+ const sessions = pageFiles.map((filename) => {
550
+ const historyPath = join(sessionsDir, filename);
551
+ try {
552
+ const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
553
+ const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
554
+ // Return minimal metadata - only prompt previews, not full movement data
555
+ const movementPreviews = (historyData.movements || []).slice(0, 3).map((m) => ({
556
+ userPrompt: m.userPrompt?.slice(0, 100) || ''
557
+ }));
558
+ return {
559
+ sessionId: historyData.sessionId,
560
+ startedAt: historyData.startedAt,
561
+ lastActivityAt: historyData.lastActivityAt,
562
+ totalTokens: historyData.totalTokens,
563
+ movementCount: historyData.movements?.length || 0,
564
+ title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
565
+ movements: movementPreviews
566
+ };
567
+ }
568
+ catch {
569
+ return null;
570
+ }
571
+ }).filter(Boolean);
572
+ return {
573
+ sessions,
574
+ total,
575
+ hasMore: offset + limit < total
576
+ };
577
+ }
578
+ /**
579
+ * Get a full session by ID (includes all movement data)
580
+ */
581
+ getSessionById(workingDir, sessionId) {
582
+ const sessionsDir = join(workingDir, '.mstro', 'improvise');
583
+ if (!existsSync(sessionsDir)) {
584
+ return null;
585
+ }
586
+ const historyFiles = readdirSync(sessionsDir)
587
+ .filter((name) => name.startsWith('history-') && name.endsWith('.json'));
588
+ for (const filename of historyFiles) {
589
+ const historyPath = join(sessionsDir, filename);
590
+ try {
591
+ const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
592
+ if (historyData.sessionId === sessionId) {
593
+ const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
594
+ return {
595
+ sessionId: historyData.sessionId,
596
+ startedAt: historyData.startedAt,
597
+ lastActivityAt: historyData.lastActivityAt,
598
+ totalTokens: historyData.totalTokens,
599
+ movementCount: historyData.movements?.length || 0,
600
+ title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
601
+ movements: historyData.movements || [],
602
+ };
603
+ }
604
+ }
605
+ catch {
606
+ // Skip files that can't be parsed
607
+ }
608
+ }
609
+ return null;
610
+ }
611
+ /**
612
+ * Delete a single session from disk
613
+ */
614
+ deleteSession(workingDir, sessionId) {
615
+ const sessionsDir = join(workingDir, '.mstro', 'improvise');
616
+ if (!existsSync(sessionsDir)) {
617
+ return { sessionId, success: false };
618
+ }
619
+ try {
620
+ const historyFiles = readdirSync(sessionsDir)
621
+ .filter((name) => name.startsWith('history-') && name.endsWith('.json'));
622
+ for (const filename of historyFiles) {
623
+ const historyPath = join(sessionsDir, filename);
624
+ try {
625
+ const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
626
+ if (historyData.sessionId === sessionId) {
627
+ unlinkSync(historyPath);
628
+ return { sessionId, success: true };
629
+ }
630
+ }
631
+ catch {
632
+ // Skip files that can't be parsed
633
+ }
634
+ }
635
+ return { sessionId, success: false };
636
+ }
637
+ catch (error) {
638
+ console.error('[WebSocketImproviseHandler] Error deleting session:', error);
639
+ return { sessionId, success: false };
640
+ }
641
+ }
642
+ /**
643
+ * Clear all sessions from disk
644
+ */
645
+ clearAllSessions(workingDir) {
646
+ const sessionsDir = join(workingDir, '.mstro', 'improvise');
647
+ if (!existsSync(sessionsDir)) {
648
+ return { success: true, deletedCount: 0 };
649
+ }
650
+ try {
651
+ const historyFiles = readdirSync(sessionsDir)
652
+ .filter((name) => name.startsWith('history-') && name.endsWith('.json'));
653
+ let deletedCount = 0;
654
+ for (const filename of historyFiles) {
655
+ const historyPath = join(sessionsDir, filename);
656
+ try {
657
+ unlinkSync(historyPath);
658
+ deletedCount++;
659
+ }
660
+ catch {
661
+ // Skip files that can't be deleted
662
+ }
663
+ }
664
+ return { success: true, deletedCount };
665
+ }
666
+ catch (error) {
667
+ console.error('[WebSocketImproviseHandler] Error clearing sessions:', error);
668
+ return { success: false, deletedCount: 0 };
669
+ }
670
+ }
671
+ /**
672
+ * Search sessions using grep on the history directory
673
+ * Searches through session file contents for matching text
674
+ * Returns paginated results with minimal metadata
675
+ */
676
+ movementMatchesQuery(movements, lowerQuery) {
677
+ if (!movements)
678
+ return false;
679
+ return movements.some((m) => m.userPrompt?.toLowerCase().includes(lowerQuery) ||
680
+ m.summary?.toLowerCase().includes(lowerQuery) ||
681
+ m.assistantResponse?.toLowerCase().includes(lowerQuery));
682
+ }
683
+ buildSessionSummary(historyData) {
684
+ const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
685
+ const movementPreviews = (historyData.movements || []).slice(0, 3).map((m) => ({
686
+ userPrompt: m.userPrompt?.slice(0, 100) || ''
687
+ }));
688
+ return {
689
+ sessionId: historyData.sessionId,
690
+ startedAt: historyData.startedAt,
691
+ lastActivityAt: historyData.lastActivityAt,
692
+ totalTokens: historyData.totalTokens,
693
+ movementCount: historyData.movements?.length || 0,
694
+ title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
695
+ movements: movementPreviews
696
+ };
697
+ }
698
+ searchSessions(workingDir, query, limit = 20, offset = 0) {
699
+ const sessionsDir = join(workingDir, '.mstro', 'improvise');
700
+ if (!existsSync(sessionsDir)) {
701
+ return { sessions: [], total: 0, hasMore: false };
702
+ }
703
+ const lowerQuery = query.toLowerCase();
704
+ try {
705
+ const historyFiles = readdirSync(sessionsDir)
706
+ .filter((name) => name.startsWith('history-') && name.endsWith('.json'))
707
+ .sort((a, b) => {
708
+ const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
709
+ const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
710
+ return timestampB - timestampA;
711
+ });
712
+ const allMatches = [];
713
+ for (const filename of historyFiles) {
714
+ try {
715
+ const content = readFileSync(join(sessionsDir, filename), 'utf-8');
716
+ const historyData = JSON.parse(content);
717
+ if (this.movementMatchesQuery(historyData.movements, lowerQuery)) {
718
+ allMatches.push(this.buildSessionSummary(historyData));
719
+ }
720
+ }
721
+ catch {
722
+ // Skip files that can't be parsed
723
+ }
724
+ }
725
+ const total = allMatches.length;
726
+ return {
727
+ sessions: allMatches.slice(offset, offset + limit),
728
+ total,
729
+ hasMore: offset + limit < total
730
+ };
731
+ }
732
+ catch (error) {
733
+ console.error('[WebSocketImproviseHandler] Error searching sessions:', error);
734
+ return { sessions: [], total: 0, hasMore: false };
735
+ }
736
+ }
737
+ /**
738
+ * Cleanup session
739
+ */
740
+ cleanupSession(sessionId) {
741
+ this.sessions.delete(sessionId);
742
+ }
743
+ /**
744
+ * Clean up stale sessions
745
+ */
746
+ cleanupStaleSessions() {
747
+ }
748
+ /**
749
+ * Generate a notification summary using Claude Haiku
750
+ * Sends the result as a notificationSummary message
751
+ */
752
+ async generateNotificationSummary(ws, tabId, userPrompt, output, workingDir) {
753
+ try {
754
+ // Create temp directory if it doesn't exist
755
+ const tempDir = join(workingDir, '.mstro', 'tmp');
756
+ if (!existsSync(tempDir)) {
757
+ mkdirSync(tempDir, { recursive: true });
758
+ }
759
+ // Truncate output if too long (keep first and last parts for context)
760
+ let truncatedOutput = output;
761
+ if (output.length > 4000) {
762
+ const firstPart = output.slice(0, 2000);
763
+ const lastPart = output.slice(-1500);
764
+ truncatedOutput = `${firstPart}\n\n... [output truncated] ...\n\n${lastPart}`;
765
+ }
766
+ // Build the prompt for summary generation
767
+ const summaryPrompt = `You are generating a SHORT browser notification summary for a completed task.
768
+ The user ran a task and wants a brief notification to remind them what happened.
769
+
770
+ USER'S ORIGINAL PROMPT:
771
+ "${userPrompt}"
772
+
773
+ TASK OUTPUT (may be truncated):
774
+ ${truncatedOutput}
775
+
776
+ Generate a notification summary following these rules:
777
+ 1. Maximum 100 characters (this is a browser notification)
778
+ 2. Focus on the OUTCOME, not the process
779
+ 3. Be specific about what was accomplished
780
+ 4. Use past tense (e.g., "Fixed bug in auth.ts", "Added 3 new tests")
781
+ 5. If there was an error, mention it briefly
782
+ 6. No emojis, no markdown, just plain text
783
+
784
+ Respond with ONLY the summary text, nothing else.`;
785
+ // Write prompt to temp file
786
+ const promptFile = join(tempDir, `notif-summary-${Date.now()}.txt`);
787
+ writeFileSync(promptFile, summaryPrompt);
788
+ const systemPrompt = 'You are a notification summary assistant. Respond with only the summary text, no preamble or explanation.';
789
+ const args = [
790
+ '--print',
791
+ '--model', 'haiku',
792
+ '--system-prompt', systemPrompt,
793
+ promptFile
794
+ ];
795
+ const claude = spawn('claude', args, {
796
+ cwd: workingDir,
797
+ stdio: ['ignore', 'pipe', 'pipe']
798
+ });
799
+ let stdout = '';
800
+ let stderr = '';
801
+ claude.stdout?.on('data', (data) => {
802
+ stdout += data.toString();
803
+ });
804
+ claude.stderr?.on('data', (data) => {
805
+ stderr += data.toString();
806
+ });
807
+ claude.on('close', (code) => {
808
+ // Clean up temp file
809
+ try {
810
+ unlinkSync(promptFile);
811
+ }
812
+ catch {
813
+ // Ignore cleanup errors
814
+ }
815
+ let summary;
816
+ if (code === 0 && stdout.trim()) {
817
+ // Truncate if somehow still too long
818
+ summary = stdout.trim().slice(0, 150);
819
+ }
820
+ else {
821
+ console.error('[WebSocketImproviseHandler] Claude error:', stderr || 'Unknown error');
822
+ // Fallback to basic summary
823
+ summary = this.createFallbackSummary(userPrompt);
824
+ }
825
+ this.send(ws, {
826
+ type: 'notificationSummary',
827
+ tabId,
828
+ data: { summary }
829
+ });
830
+ });
831
+ claude.on('error', (err) => {
832
+ console.error('[WebSocketImproviseHandler] Failed to spawn Claude:', err);
833
+ const summary = this.createFallbackSummary(userPrompt);
834
+ this.send(ws, {
835
+ type: 'notificationSummary',
836
+ tabId,
837
+ data: { summary }
838
+ });
839
+ });
840
+ // Timeout after 10 seconds
841
+ setTimeout(() => {
842
+ claude.kill();
843
+ const summary = this.createFallbackSummary(userPrompt);
844
+ this.send(ws, {
845
+ type: 'notificationSummary',
846
+ tabId,
847
+ data: { summary }
848
+ });
849
+ }, 10000);
850
+ }
851
+ catch (error) {
852
+ console.error('[WebSocketImproviseHandler] Error generating summary:', error);
853
+ const summary = this.createFallbackSummary(userPrompt);
854
+ this.send(ws, {
855
+ type: 'notificationSummary',
856
+ tabId,
857
+ data: { summary }
858
+ });
859
+ }
860
+ }
861
+ /**
862
+ * Create a fallback summary when AI summarization fails
863
+ */
864
+ createFallbackSummary(userPrompt) {
865
+ const truncated = userPrompt.slice(0, 60);
866
+ if (userPrompt.length > 60) {
867
+ return `Completed: "${truncated}..."`;
868
+ }
869
+ return `Completed: "${truncated}"`;
870
+ }
871
+ // ============================================
872
+ // Git handling methods
873
+ // ============================================
874
+ /**
875
+ * Handle git-related messages
876
+ */
877
+ handleGitMessage(ws, msg, tabId, workingDir) {
878
+ // Get the effective git directory (selected or working dir)
879
+ const gitDir = this.gitDirectories.get(tabId) || workingDir;
880
+ const handlers = {
881
+ gitStatus: () => this.handleGitStatus(ws, tabId, gitDir),
882
+ gitStage: () => this.handleGitStage(ws, msg, tabId, gitDir),
883
+ gitUnstage: () => this.handleGitUnstage(ws, msg, tabId, gitDir),
884
+ gitCommit: () => this.handleGitCommit(ws, msg, tabId, gitDir),
885
+ gitCommitWithAI: () => this.handleGitCommitWithAI(ws, msg, tabId, gitDir),
886
+ gitPush: () => this.handleGitPush(ws, tabId, gitDir),
887
+ gitLog: () => this.handleGitLog(ws, msg, tabId, gitDir),
888
+ gitDiscoverRepos: () => this.handleGitDiscoverRepos(ws, tabId, workingDir),
889
+ gitSetDirectory: () => this.handleGitSetDirectory(ws, msg, tabId, workingDir),
890
+ };
891
+ handlers[msg.type]?.();
892
+ }
893
+ /**
894
+ * Execute a git command and return stdout
895
+ */
896
+ executeGitCommand(args, workingDir) {
897
+ return new Promise((resolve) => {
898
+ const git = spawn('git', args, {
899
+ cwd: workingDir,
900
+ stdio: ['ignore', 'pipe', 'pipe']
901
+ });
902
+ let stdout = '';
903
+ let stderr = '';
904
+ git.stdout?.on('data', (data) => {
905
+ stdout += data.toString();
906
+ });
907
+ git.stderr?.on('data', (data) => {
908
+ stderr += data.toString();
909
+ });
910
+ git.on('close', (code) => {
911
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
912
+ });
913
+ git.on('error', (err) => {
914
+ resolve({ stdout: '', stderr: err.message, exitCode: 1 });
915
+ });
916
+ });
917
+ }
918
+ /**
919
+ * Parse git status --porcelain output into structured format
920
+ */
921
+ parseGitStatus(porcelainOutput) {
922
+ const staged = [];
923
+ const unstaged = [];
924
+ const untracked = [];
925
+ const lines = porcelainOutput.trim().split('\n').filter(Boolean);
926
+ for (const line of lines) {
927
+ if (line.length < 4)
928
+ continue;
929
+ const indexStatus = line[0];
930
+ const workTreeStatus = line[1];
931
+ const path = line.slice(3);
932
+ // Handle renamed files (format: "R old -> new")
933
+ let filePath = path;
934
+ let originalPath;
935
+ if (path.includes(' -> ')) {
936
+ const parts = path.split(' -> ');
937
+ originalPath = parts[0];
938
+ filePath = parts[1];
939
+ }
940
+ // Untracked files
941
+ if (indexStatus === '?' && workTreeStatus === '?') {
942
+ untracked.push({
943
+ path: filePath,
944
+ status: '?',
945
+ staged: false,
946
+ });
947
+ continue;
948
+ }
949
+ // Staged changes (index has changes)
950
+ if (indexStatus !== ' ' && indexStatus !== '?') {
951
+ staged.push({
952
+ path: filePath,
953
+ status: indexStatus,
954
+ staged: true,
955
+ originalPath,
956
+ });
957
+ }
958
+ // Unstaged changes (worktree has changes)
959
+ if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
960
+ unstaged.push({
961
+ path: filePath,
962
+ status: workTreeStatus,
963
+ staged: false,
964
+ originalPath,
965
+ });
966
+ }
967
+ }
968
+ return { staged, unstaged, untracked };
969
+ }
970
+ /**
971
+ * Handle git status request
972
+ */
973
+ async handleGitStatus(ws, tabId, workingDir) {
974
+ try {
975
+ // Get porcelain status
976
+ const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
977
+ if (statusResult.exitCode !== 0) {
978
+ this.send(ws, { type: 'gitError', tabId, data: { error: statusResult.stderr || statusResult.stdout || 'Failed to get git status' } });
979
+ return;
980
+ }
981
+ // Get current branch
982
+ const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
983
+ const branch = branchResult.stdout.trim() || 'HEAD';
984
+ // Get ahead/behind counts
985
+ let ahead = 0;
986
+ let behind = 0;
987
+ const trackingResult = await this.executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
988
+ if (trackingResult.exitCode === 0) {
989
+ const parts = trackingResult.stdout.trim().split(/\s+/);
990
+ ahead = parseInt(parts[0], 10) || 0;
991
+ behind = parseInt(parts[1], 10) || 0;
992
+ }
993
+ const { staged, unstaged, untracked } = this.parseGitStatus(statusResult.stdout);
994
+ const response = {
995
+ branch,
996
+ isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0,
997
+ staged,
998
+ unstaged,
999
+ untracked,
1000
+ ahead,
1001
+ behind,
1002
+ };
1003
+ this.send(ws, { type: 'gitStatus', tabId, data: response });
1004
+ }
1005
+ catch (error) {
1006
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1007
+ }
1008
+ }
1009
+ /**
1010
+ * Handle git stage request
1011
+ */
1012
+ async handleGitStage(ws, msg, tabId, workingDir) {
1013
+ const paths = msg.data?.paths;
1014
+ if (!paths || paths.length === 0) {
1015
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for staging' } });
1016
+ return;
1017
+ }
1018
+ try {
1019
+ const result = await this.executeGitCommand(['add', '--', ...paths], workingDir);
1020
+ if (result.exitCode !== 0) {
1021
+ this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to stage files' } });
1022
+ return;
1023
+ }
1024
+ // Verify files were actually staged by checking status
1025
+ const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1026
+ const { staged } = this.parseGitStatus(statusResult.stdout);
1027
+ const stagedPaths = staged.map(f => f.path);
1028
+ // Check if all requested files are now staged
1029
+ const notStaged = paths.filter(p => !stagedPaths.includes(p));
1030
+ if (notStaged.length > 0) {
1031
+ // Some files weren't staged - they might not exist or have no changes
1032
+ this.send(ws, { type: 'gitError', tabId, data: { error: `Some files could not be staged: ${notStaged.join(', ')}` } });
1033
+ }
1034
+ this.send(ws, { type: 'gitStaged', tabId, data: { paths } });
1035
+ }
1036
+ catch (error) {
1037
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Handle git unstage request
1042
+ */
1043
+ async handleGitUnstage(ws, msg, tabId, workingDir) {
1044
+ const paths = msg.data?.paths;
1045
+ if (!paths || paths.length === 0) {
1046
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for unstaging' } });
1047
+ return;
1048
+ }
1049
+ try {
1050
+ const result = await this.executeGitCommand(['reset', 'HEAD', '--', ...paths], workingDir);
1051
+ if (result.exitCode !== 0) {
1052
+ this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to unstage files' } });
1053
+ return;
1054
+ }
1055
+ this.send(ws, { type: 'gitUnstaged', tabId, data: { paths } });
1056
+ }
1057
+ catch (error) {
1058
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1059
+ }
1060
+ }
1061
+ /**
1062
+ * Handle git commit request (with user-provided message)
1063
+ */
1064
+ async handleGitCommit(ws, msg, tabId, workingDir) {
1065
+ const message = msg.data?.message;
1066
+ if (!message) {
1067
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Commit message is required' } });
1068
+ return;
1069
+ }
1070
+ try {
1071
+ // First check if there are actually staged changes
1072
+ const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1073
+ const { staged } = this.parseGitStatus(statusResult.stdout);
1074
+ if (staged.length === 0) {
1075
+ // No staged changes - refresh status on client and show clear error
1076
+ this.handleGitStatus(ws, tabId, workingDir);
1077
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'No changes staged for commit. Use "Stage" to add files before committing.' } });
1078
+ return;
1079
+ }
1080
+ const result = await this.executeGitCommand(['commit', '-m', message], workingDir);
1081
+ if (result.exitCode !== 0) {
1082
+ // Parse the error to provide a cleaner message
1083
+ let errorMsg = result.stderr || result.stdout || 'Failed to commit';
1084
+ // If it's a "nothing to commit" error, provide clearer message
1085
+ if (errorMsg.includes('nothing to commit') || errorMsg.includes('no changes added')) {
1086
+ errorMsg = 'No changes staged for commit. Use "Stage" to add files before committing.';
1087
+ // Refresh status to sync UI
1088
+ this.handleGitStatus(ws, tabId, workingDir);
1089
+ }
1090
+ this.send(ws, { type: 'gitError', tabId, data: { error: errorMsg } });
1091
+ return;
1092
+ }
1093
+ // Get the new commit hash
1094
+ const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
1095
+ const hash = hashResult.stdout.trim();
1096
+ this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message } });
1097
+ }
1098
+ catch (error) {
1099
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Handle git commit with AI-generated message
1104
+ * Uses Claude Code to analyze staged changes and generate a commit message
1105
+ */
1106
+ async handleGitCommitWithAI(ws, msg, tabId, workingDir) {
1107
+ try {
1108
+ // First check if there are staged changes
1109
+ const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1110
+ const { staged } = this.parseGitStatus(statusResult.stdout);
1111
+ if (staged.length === 0) {
1112
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'No staged changes to commit' } });
1113
+ return;
1114
+ }
1115
+ // Get the diff of staged changes
1116
+ const diffResult = await this.executeGitCommand(['diff', '--cached'], workingDir);
1117
+ const diff = diffResult.stdout;
1118
+ // Get recent commit messages for style reference
1119
+ const logResult = await this.executeGitCommand(['log', '--oneline', '-5'], workingDir);
1120
+ const recentCommits = logResult.stdout.trim();
1121
+ // Create temp directory if it doesn't exist
1122
+ const tempDir = join(workingDir, '.mstro', 'tmp');
1123
+ if (!existsSync(tempDir)) {
1124
+ mkdirSync(tempDir, { recursive: true });
1125
+ }
1126
+ // Truncate diff if too long
1127
+ let truncatedDiff = diff;
1128
+ if (diff.length > 8000) {
1129
+ truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
1130
+ }
1131
+ // Build prompt for commit message generation
1132
+ const prompt = `You are generating a git commit message for the following staged changes.
1133
+
1134
+ RECENT COMMIT MESSAGES (for style reference):
1135
+ ${recentCommits || 'No recent commits'}
1136
+
1137
+ STAGED FILES:
1138
+ ${staged.map(f => `${f.status} ${f.path}`).join('\n')}
1139
+
1140
+ DIFF OF STAGED CHANGES:
1141
+ ${truncatedDiff}
1142
+
1143
+ Generate a commit message following these rules:
1144
+ 1. First line: imperative mood, max 72 characters (e.g., "Add user authentication", "Fix memory leak in parser")
1145
+ 2. If the changes are complex, add a blank line then bullet points explaining the key changes
1146
+ 3. Focus on the "why" not just the "what"
1147
+ 4. Match the style of recent commits if possible
1148
+ 5. No emojis unless the repo already uses them
1149
+
1150
+ Respond with ONLY the commit message, nothing else.`;
1151
+ // Write prompt to temp file
1152
+ const promptFile = join(tempDir, `commit-msg-${Date.now()}.txt`);
1153
+ writeFileSync(promptFile, prompt);
1154
+ const systemPrompt = 'You are a commit message assistant. Respond with only the commit message, no preamble or explanation.';
1155
+ const args = [
1156
+ '--print',
1157
+ '--model', 'haiku',
1158
+ '--system-prompt', systemPrompt,
1159
+ promptFile
1160
+ ];
1161
+ const claude = spawn('claude', args, {
1162
+ cwd: workingDir,
1163
+ stdio: ['ignore', 'pipe', 'pipe']
1164
+ });
1165
+ let stdout = '';
1166
+ let stderr = '';
1167
+ claude.stdout?.on('data', (data) => {
1168
+ stdout += data.toString();
1169
+ });
1170
+ claude.stderr?.on('data', (data) => {
1171
+ stderr += data.toString();
1172
+ });
1173
+ claude.on('close', async (code) => {
1174
+ // Clean up temp file
1175
+ try {
1176
+ unlinkSync(promptFile);
1177
+ }
1178
+ catch {
1179
+ // Ignore cleanup errors
1180
+ }
1181
+ if (code !== 0 || !stdout.trim()) {
1182
+ console.error('[WebSocketImproviseHandler] Claude commit message error:', stderr || 'No output');
1183
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
1184
+ return;
1185
+ }
1186
+ // Post-process to extract just the commit message
1187
+ // Claude sometimes outputs reasoning before the actual message
1188
+ const commitMessage = this.extractCommitMessage(stdout.trim());
1189
+ const autoCommit = !!msg.data?.autoCommit;
1190
+ // Send the generated message for preview (include autoCommit flag so frontend knows if commit is pending)
1191
+ this.send(ws, { type: 'gitCommitMessage', tabId, data: { message: commitMessage, autoCommit } });
1192
+ // If autoCommit is true, proceed with the commit
1193
+ if (msg.data?.autoCommit) {
1194
+ const commitResult = await this.executeGitCommand(['commit', '-m', commitMessage], workingDir);
1195
+ if (commitResult.exitCode !== 0) {
1196
+ this.send(ws, { type: 'gitError', tabId, data: { error: commitResult.stderr || commitResult.stdout || 'Failed to commit' } });
1197
+ return;
1198
+ }
1199
+ // Get the new commit hash
1200
+ const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
1201
+ const hash = hashResult.stdout.trim();
1202
+ this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message: commitMessage } });
1203
+ }
1204
+ });
1205
+ claude.on('error', (err) => {
1206
+ console.error('[WebSocketImproviseHandler] Failed to spawn Claude for commit:', err);
1207
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
1208
+ });
1209
+ // Timeout after 30 seconds
1210
+ setTimeout(() => {
1211
+ claude.kill();
1212
+ }, 30000);
1213
+ }
1214
+ catch (error) {
1215
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1216
+ }
1217
+ }
1218
+ /**
1219
+ * Extract the actual commit message from Claude's output.
1220
+ * Sometimes Claude outputs reasoning before the actual message, so we need to parse it.
1221
+ */
1222
+ extractCommitMessage(output) {
1223
+ // Look for common patterns where Claude introduces the commit message
1224
+ const patterns = [
1225
+ /(?:here'?s?\s+(?:the\s+)?commit\s+message:?\s*\n+)([\s\S]+)/i,
1226
+ /(?:commit\s+message:?\s*\n+)([\s\S]+)/i,
1227
+ /(?:suggested\s+commit\s+message:?\s*\n+)([\s\S]+)/i,
1228
+ ];
1229
+ for (const pattern of patterns) {
1230
+ const match = output.match(pattern);
1231
+ if (match?.[1]) {
1232
+ return match[1].trim();
1233
+ }
1234
+ }
1235
+ // Split into paragraphs for analysis
1236
+ const paragraphs = output.split(/\n\n+/).filter(p => p.trim());
1237
+ // If only one paragraph, return it as-is
1238
+ if (paragraphs.length <= 1) {
1239
+ return output.trim();
1240
+ }
1241
+ const firstParagraph = paragraphs[0].trim();
1242
+ const firstLine = firstParagraph.split('\n')[0].trim();
1243
+ // Check if first paragraph looks like reasoning/self-talk
1244
+ // Reasoning typically: starts with certain words, is conversational, explains what will happen
1245
+ const reasoningPatterns = [
1246
+ /^(Now|Based|Looking|After|Here|Let me|I\s+(can|will|see|notice|'ll|would))/i,
1247
+ /^The\s+\w+\s+(file|changes?|commit|diff)/i,
1248
+ /\b(I can|I will|I'll|let me|analyzing|looking at)\b/i,
1249
+ ];
1250
+ const looksLikeReasoning = reasoningPatterns.some(p => p.test(firstParagraph));
1251
+ // Also check if first line is too long or conversational for a commit title
1252
+ const firstLineTooLong = firstLine.length > 80;
1253
+ const endsWithPeriod = firstLine.endsWith('.');
1254
+ if (looksLikeReasoning || (firstLineTooLong && endsWithPeriod)) {
1255
+ // Skip the first paragraph (reasoning) and return the rest
1256
+ const commitMessage = paragraphs.slice(1).join('\n\n').trim();
1257
+ // Validate the extracted message has a reasonable first line
1258
+ const extractedFirstLine = commitMessage.split('\n')[0].trim();
1259
+ if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
1260
+ return commitMessage;
1261
+ }
1262
+ }
1263
+ // Check if the second paragraph looks like a proper commit title
1264
+ // (short, starts with capital, imperative mood)
1265
+ if (paragraphs.length >= 2) {
1266
+ const secondParagraph = paragraphs[1].trim();
1267
+ const secondFirstLine = secondParagraph.split('\n')[0].trim();
1268
+ // Commit titles are typically short and start with imperative verb
1269
+ if (secondFirstLine.length <= 72 &&
1270
+ /^[A-Z][a-z]/.test(secondFirstLine) &&
1271
+ !secondFirstLine.endsWith('.')) {
1272
+ // Return from second paragraph onwards
1273
+ return paragraphs.slice(1).join('\n\n').trim();
1274
+ }
1275
+ }
1276
+ // Fall back to original output if we can't identify a better message
1277
+ return output.trim();
1278
+ }
1279
+ /**
1280
+ * Handle git push request
1281
+ */
1282
+ async handleGitPush(ws, tabId, workingDir) {
1283
+ try {
1284
+ const result = await this.executeGitCommand(['push'], workingDir);
1285
+ if (result.exitCode !== 0) {
1286
+ this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
1287
+ return;
1288
+ }
1289
+ this.send(ws, { type: 'gitPushed', tabId, data: { output: result.stdout || result.stderr } });
1290
+ }
1291
+ catch (error) {
1292
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1293
+ }
1294
+ }
1295
+ /**
1296
+ * Handle git log request
1297
+ */
1298
+ async handleGitLog(ws, msg, tabId, workingDir) {
1299
+ const limit = msg.data?.limit ?? 10;
1300
+ try {
1301
+ const result = await this.executeGitCommand([
1302
+ 'log',
1303
+ `-${limit}`,
1304
+ '--format=%H|%h|%s|%an|%aI'
1305
+ ], workingDir);
1306
+ if (result.exitCode !== 0) {
1307
+ this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to get log' } });
1308
+ return;
1309
+ }
1310
+ const entries = result.stdout.trim().split('\n').filter(Boolean).map(line => {
1311
+ const [hash, shortHash, subject, author, date] = line.split('|');
1312
+ return { hash, shortHash, subject, author, date };
1313
+ });
1314
+ this.send(ws, { type: 'gitLog', tabId, data: { entries } });
1315
+ }
1316
+ catch (error) {
1317
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1318
+ }
1319
+ }
1320
+ /** Directories to skip when scanning for git repos */
1321
+ static SKIP_DIRS = ['node_modules', 'vendor', '.git'];
1322
+ /** Get the current branch name for a git repository */
1323
+ async getRepoBranch(repoPath) {
1324
+ const result = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
1325
+ return result.exitCode === 0 ? result.stdout.trim() : undefined;
1326
+ }
1327
+ /** Check if a directory name should be skipped when scanning */
1328
+ shouldSkipDir(name) {
1329
+ return name.startsWith('.') || WebSocketImproviseHandler.SKIP_DIRS.includes(name);
1330
+ }
1331
+ /** Recursively scan directories for git repositories */
1332
+ async scanForGitRepos(dir, depth, maxDepth, repos) {
1333
+ if (depth > maxDepth)
1334
+ return;
1335
+ let entries;
1336
+ try {
1337
+ entries = readdirSync(dir);
1338
+ }
1339
+ catch {
1340
+ return;
1341
+ }
1342
+ for (const name of entries) {
1343
+ if (this.shouldSkipDir(name))
1344
+ continue;
1345
+ const fullPath = join(dir, name);
1346
+ const gitPath = join(fullPath, '.git');
1347
+ if (existsSync(gitPath)) {
1348
+ repos.push({ path: fullPath, name, branch: await this.getRepoBranch(fullPath) });
1349
+ }
1350
+ else {
1351
+ await this.scanForGitRepos(fullPath, depth + 1, maxDepth, repos);
1352
+ }
1353
+ }
1354
+ }
1355
+ /**
1356
+ * Discover git repositories in the working directory and subdirectories
1357
+ */
1358
+ async handleGitDiscoverRepos(ws, tabId, workingDir) {
1359
+ try {
1360
+ const repos = [];
1361
+ const rootIsGitRepo = existsSync(join(workingDir, '.git'));
1362
+ if (rootIsGitRepo) {
1363
+ repos.push({
1364
+ path: workingDir,
1365
+ name: workingDir.split('/').pop() || workingDir,
1366
+ branch: await this.getRepoBranch(workingDir),
1367
+ });
1368
+ }
1369
+ else {
1370
+ await this.scanForGitRepos(workingDir, 1, 3, repos);
1371
+ }
1372
+ const response = {
1373
+ repos,
1374
+ rootIsGitRepo,
1375
+ selectedDirectory: this.gitDirectories.get(tabId) || null,
1376
+ };
1377
+ this.send(ws, { type: 'gitReposDiscovered', tabId, data: response });
1378
+ }
1379
+ catch (error) {
1380
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1381
+ }
1382
+ }
1383
+ /**
1384
+ * Set the git directory for operations
1385
+ */
1386
+ async handleGitSetDirectory(ws, msg, tabId, workingDir) {
1387
+ const directory = msg.data?.directory;
1388
+ if (!directory) {
1389
+ // Clear the selected directory, use working dir
1390
+ this.gitDirectories.delete(tabId);
1391
+ const response = {
1392
+ directory: workingDir,
1393
+ isValid: existsSync(join(workingDir, '.git')),
1394
+ };
1395
+ this.send(ws, { type: 'gitDirectorySet', tabId, data: response });
1396
+ // Refresh status with new directory
1397
+ this.handleGitStatus(ws, tabId, workingDir);
1398
+ return;
1399
+ }
1400
+ // Validate the directory exists and has a .git folder
1401
+ const gitPath = join(directory, '.git');
1402
+ const isValid = existsSync(gitPath);
1403
+ if (isValid) {
1404
+ this.gitDirectories.set(tabId, directory);
1405
+ }
1406
+ const response = {
1407
+ directory,
1408
+ isValid,
1409
+ };
1410
+ this.send(ws, { type: 'gitDirectorySet', tabId, data: response });
1411
+ // Refresh status with new directory
1412
+ if (isValid) {
1413
+ this.handleGitStatus(ws, tabId, directory);
1414
+ this.handleGitLog(ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
1415
+ }
1416
+ }
1417
+ // ============================================
1418
+ // Terminal handling methods
1419
+ // ============================================
1420
+ /**
1421
+ * Initialize a new terminal session or reconnect to existing one
1422
+ */
1423
+ handleTerminalInit(ws, terminalId, workingDir, requestedShell, cols, rows) {
1424
+ const ptyManager = getPTYManager();
1425
+ // Check if PTY is available (node-pty requires native compilation)
1426
+ if (!ptyManager.isPtyAvailable()) {
1427
+ this.send(ws, {
1428
+ type: 'terminalError',
1429
+ terminalId,
1430
+ data: {
1431
+ error: 'PTY_NOT_AVAILABLE',
1432
+ instructions: ptyManager.getPtyInstallInstructions()
1433
+ }
1434
+ });
1435
+ return;
1436
+ }
1437
+ // Clean up any existing listeners for this terminal to prevent duplicates
1438
+ const existingCleanup = this.terminalListenerCleanups.get(terminalId);
1439
+ if (existingCleanup) {
1440
+ existingCleanup();
1441
+ }
1442
+ // Set up event listeners for this terminal
1443
+ const onOutput = (tid, data) => {
1444
+ if (tid === terminalId) {
1445
+ this.send(ws, {
1446
+ type: 'terminalOutput',
1447
+ terminalId,
1448
+ data: { output: data }
1449
+ });
1450
+ }
1451
+ };
1452
+ const onExit = (tid, exitCode) => {
1453
+ if (tid === terminalId) {
1454
+ this.send(ws, {
1455
+ type: 'terminalExit',
1456
+ terminalId,
1457
+ data: { exitCode }
1458
+ });
1459
+ // Clean up listeners
1460
+ ptyManager.off('output', onOutput);
1461
+ ptyManager.off('exit', onExit);
1462
+ ptyManager.off('error', onError);
1463
+ this.terminalListenerCleanups.delete(terminalId);
1464
+ }
1465
+ };
1466
+ const onError = (tid, error) => {
1467
+ if (tid === terminalId) {
1468
+ this.send(ws, {
1469
+ type: 'terminalError',
1470
+ terminalId,
1471
+ data: { error }
1472
+ });
1473
+ }
1474
+ };
1475
+ ptyManager.on('output', onOutput);
1476
+ ptyManager.on('exit', onExit);
1477
+ ptyManager.on('error', onError);
1478
+ // Store cleanup function for this terminal
1479
+ this.terminalListenerCleanups.set(terminalId, () => {
1480
+ ptyManager.off('output', onOutput);
1481
+ ptyManager.off('exit', onExit);
1482
+ ptyManager.off('error', onError);
1483
+ });
1484
+ try {
1485
+ // Create or reconnect to the PTY process
1486
+ const { shell, cwd, isReconnect } = ptyManager.create(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
1487
+ // If reconnecting, send scrollback buffer first
1488
+ if (isReconnect) {
1489
+ const scrollback = ptyManager.getScrollback(terminalId);
1490
+ if (scrollback.length > 0) {
1491
+ // Send scrollback as a single replay message
1492
+ this.send(ws, {
1493
+ type: 'terminalScrollback',
1494
+ terminalId,
1495
+ data: { lines: scrollback }
1496
+ });
1497
+ }
1498
+ }
1499
+ // Send ready message
1500
+ this.send(ws, {
1501
+ type: 'terminalReady',
1502
+ terminalId,
1503
+ data: { shell, cwd, isReconnect }
1504
+ });
1505
+ }
1506
+ catch (error) {
1507
+ console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
1508
+ this.send(ws, {
1509
+ type: 'terminalError',
1510
+ terminalId,
1511
+ data: { error: error.message || 'Failed to create terminal' }
1512
+ });
1513
+ // Clean up listeners
1514
+ ptyManager.off('output', onOutput);
1515
+ ptyManager.off('exit', onExit);
1516
+ ptyManager.off('error', onError);
1517
+ this.terminalListenerCleanups.delete(terminalId);
1518
+ }
1519
+ }
1520
+ /**
1521
+ * Reconnect to an existing terminal session
1522
+ */
1523
+ handleTerminalReconnect(ws, terminalId) {
1524
+ const ptyManager = getPTYManager();
1525
+ // Check if session exists
1526
+ const sessionInfo = ptyManager.getSessionInfo(terminalId);
1527
+ if (!sessionInfo) {
1528
+ this.send(ws, {
1529
+ type: 'terminalError',
1530
+ terminalId,
1531
+ data: { error: 'Terminal session not found', sessionNotFound: true }
1532
+ });
1533
+ return;
1534
+ }
1535
+ // Clean up any existing listeners for this terminal to prevent duplicates
1536
+ const existingCleanup = this.terminalListenerCleanups.get(terminalId);
1537
+ if (existingCleanup) {
1538
+ existingCleanup();
1539
+ }
1540
+ // Set up event listeners for this terminal
1541
+ const onOutput = (tid, data) => {
1542
+ if (tid === terminalId) {
1543
+ this.send(ws, {
1544
+ type: 'terminalOutput',
1545
+ terminalId,
1546
+ data: { output: data }
1547
+ });
1548
+ }
1549
+ };
1550
+ const onExit = (tid, exitCode) => {
1551
+ if (tid === terminalId) {
1552
+ this.send(ws, {
1553
+ type: 'terminalExit',
1554
+ terminalId,
1555
+ data: { exitCode }
1556
+ });
1557
+ ptyManager.off('output', onOutput);
1558
+ ptyManager.off('exit', onExit);
1559
+ ptyManager.off('error', onError);
1560
+ this.terminalListenerCleanups.delete(terminalId);
1561
+ }
1562
+ };
1563
+ const onError = (tid, error) => {
1564
+ if (tid === terminalId) {
1565
+ this.send(ws, {
1566
+ type: 'terminalError',
1567
+ terminalId,
1568
+ data: { error }
1569
+ });
1570
+ }
1571
+ };
1572
+ ptyManager.on('output', onOutput);
1573
+ ptyManager.on('exit', onExit);
1574
+ ptyManager.on('error', onError);
1575
+ // Store cleanup function for this terminal
1576
+ this.terminalListenerCleanups.set(terminalId, () => {
1577
+ ptyManager.off('output', onOutput);
1578
+ ptyManager.off('exit', onExit);
1579
+ ptyManager.off('error', onError);
1580
+ });
1581
+ // Send scrollback buffer
1582
+ const scrollback = ptyManager.getScrollback(terminalId);
1583
+ if (scrollback.length > 0) {
1584
+ this.send(ws, {
1585
+ type: 'terminalScrollback',
1586
+ terminalId,
1587
+ data: { lines: scrollback }
1588
+ });
1589
+ }
1590
+ // Send ready message indicating reconnection
1591
+ this.send(ws, {
1592
+ type: 'terminalReady',
1593
+ terminalId,
1594
+ data: {
1595
+ shell: sessionInfo.shell,
1596
+ cwd: sessionInfo.cwd,
1597
+ isReconnect: true
1598
+ }
1599
+ });
1600
+ // Force a resize to trigger SIGWINCH, causing the shell to redraw its prompt
1601
+ ptyManager.resize(terminalId, sessionInfo.cols, sessionInfo.rows);
1602
+ }
1603
+ /**
1604
+ * List all active terminal sessions
1605
+ */
1606
+ handleTerminalList(ws) {
1607
+ const ptyManager = getPTYManager();
1608
+ const terminalIds = ptyManager.getActiveTerminals();
1609
+ const terminals = terminalIds.map(id => {
1610
+ const info = ptyManager.getSessionInfo(id);
1611
+ return info ? { id, ...info } : null;
1612
+ }).filter(Boolean);
1613
+ this.send(ws, {
1614
+ type: 'terminalList',
1615
+ data: { terminals }
1616
+ });
1617
+ }
1618
+ /**
1619
+ * Handle terminal input
1620
+ */
1621
+ handleTerminalInput(ws, terminalId, input) {
1622
+ if (!input) {
1623
+ return;
1624
+ }
1625
+ // Check if this is a persistent terminal first
1626
+ const persistentHandler = this.persistentHandlers.get(terminalId);
1627
+ if (persistentHandler) {
1628
+ persistentHandler.write(input);
1629
+ return;
1630
+ }
1631
+ // Otherwise use regular PTY
1632
+ const ptyManager = getPTYManager();
1633
+ const success = ptyManager.write(terminalId, input);
1634
+ if (!success) {
1635
+ this.send(ws, {
1636
+ type: 'terminalError',
1637
+ terminalId,
1638
+ data: { error: 'Terminal not found or write failed' }
1639
+ });
1640
+ }
1641
+ }
1642
+ /**
1643
+ * Handle terminal resize
1644
+ */
1645
+ handleTerminalResize(_ws, terminalId, cols, rows) {
1646
+ if (!cols || !rows) {
1647
+ return;
1648
+ }
1649
+ // Check if this is a persistent terminal first
1650
+ const persistentHandler = this.persistentHandlers.get(terminalId);
1651
+ if (persistentHandler) {
1652
+ persistentHandler.resize(cols, rows);
1653
+ return;
1654
+ }
1655
+ // Otherwise use regular PTY
1656
+ const ptyManager = getPTYManager();
1657
+ ptyManager.resize(terminalId, cols, rows);
1658
+ }
1659
+ /**
1660
+ * Handle terminal close
1661
+ */
1662
+ handleTerminalClose(_ws, terminalId) {
1663
+ // Check if this is a persistent terminal first
1664
+ const persistentHandler = this.persistentHandlers.get(terminalId);
1665
+ if (persistentHandler) {
1666
+ persistentHandler.detach();
1667
+ this.persistentHandlers.delete(terminalId);
1668
+ // For persistent terminals, close actually kills the tmux session
1669
+ const ptyManager = getPTYManager();
1670
+ ptyManager.closePersistent(terminalId);
1671
+ return;
1672
+ }
1673
+ // Clean up event listeners
1674
+ const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
1675
+ if (listenerCleanup) {
1676
+ listenerCleanup();
1677
+ this.terminalListenerCleanups.delete(terminalId);
1678
+ }
1679
+ // Otherwise use regular PTY
1680
+ const ptyManager = getPTYManager();
1681
+ ptyManager.close(terminalId);
1682
+ }
1683
+ // Persistent terminal handlers for tmux-backed sessions
1684
+ persistentHandlers = new Map();
1685
+ // Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
1686
+ terminalListenerCleanups = new Map();
1687
+ /**
1688
+ * Initialize a persistent (tmux-backed) terminal session
1689
+ * These sessions survive server restarts
1690
+ */
1691
+ handleTerminalInitPersistent(ws, terminalId, workingDir, requestedShell, cols, rows) {
1692
+ const ptyManager = getPTYManager();
1693
+ // Check if tmux is available
1694
+ if (!ptyManager.isTmuxAvailable()) {
1695
+ this.send(ws, {
1696
+ type: 'terminalError',
1697
+ terminalId,
1698
+ data: { error: 'Persistent terminals require tmux, which is not installed' }
1699
+ });
1700
+ return;
1701
+ }
1702
+ try {
1703
+ // Create or reconnect to the persistent session
1704
+ const { shell, cwd, isReconnect } = ptyManager.createPersistent(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
1705
+ // Attach to the session for I/O
1706
+ const handlers = ptyManager.attachPersistent(terminalId, (output) => {
1707
+ this.send(ws, {
1708
+ type: 'terminalOutput',
1709
+ terminalId,
1710
+ data: { output }
1711
+ });
1712
+ }, (exitCode) => {
1713
+ this.send(ws, {
1714
+ type: 'terminalExit',
1715
+ terminalId,
1716
+ data: { exitCode }
1717
+ });
1718
+ this.persistentHandlers.delete(terminalId);
1719
+ });
1720
+ if (handlers) {
1721
+ this.persistentHandlers.set(terminalId, handlers);
1722
+ }
1723
+ // If reconnecting, send scrollback buffer first
1724
+ if (isReconnect) {
1725
+ const scrollback = ptyManager.getPersistentScrollback(terminalId);
1726
+ if (scrollback.length > 0) {
1727
+ this.send(ws, {
1728
+ type: 'terminalScrollback',
1729
+ terminalId,
1730
+ data: { lines: scrollback }
1731
+ });
1732
+ }
1733
+ }
1734
+ // Send ready message
1735
+ this.send(ws, {
1736
+ type: 'terminalReady',
1737
+ terminalId,
1738
+ data: { shell, cwd, isReconnect, persistent: true }
1739
+ });
1740
+ }
1741
+ catch (error) {
1742
+ console.error(`[WebSocketImproviseHandler] Failed to create persistent terminal:`, error);
1743
+ this.send(ws, {
1744
+ type: 'terminalError',
1745
+ terminalId,
1746
+ data: { error: error.message || 'Failed to create persistent terminal' }
1747
+ });
1748
+ }
1749
+ }
1750
+ /**
1751
+ * List all persistent terminal sessions (including those from previous server runs)
1752
+ */
1753
+ handleTerminalListPersistent(ws) {
1754
+ const ptyManager = getPTYManager();
1755
+ const sessions = ptyManager.getPersistentSessions();
1756
+ this.send(ws, {
1757
+ type: 'terminalListPersistent',
1758
+ data: {
1759
+ available: ptyManager.isTmuxAvailable(),
1760
+ terminals: sessions.map(s => ({
1761
+ id: s.terminalId,
1762
+ shell: s.shell,
1763
+ cwd: s.cwd,
1764
+ createdAt: s.createdAt,
1765
+ lastAttachedAt: s.lastAttachedAt,
1766
+ }))
1767
+ }
1768
+ });
1769
+ }
1770
+ }
1771
+ //# sourceMappingURL=handler.js.map