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