mstro-app 0.1.58 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +85 -42
  4. package/bin/commands/logout.js +35 -1
  5. package/bin/commands/status.js +1 -1
  6. package/bin/mstro.js +231 -131
  7. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  8. package/dist/server/cli/headless/claude-invoker.js +550 -115
  9. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  10. package/dist/server/cli/headless/index.d.ts +2 -1
  11. package/dist/server/cli/headless/index.d.ts.map +1 -1
  12. package/dist/server/cli/headless/index.js +2 -0
  13. package/dist/server/cli/headless/index.js.map +1 -1
  14. package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
  15. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
  16. package/dist/server/cli/headless/prompt-utils.js +40 -5
  17. package/dist/server/cli/headless/prompt-utils.js.map +1 -1
  18. package/dist/server/cli/headless/runner.d.ts +1 -1
  19. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  20. package/dist/server/cli/headless/runner.js +52 -7
  21. package/dist/server/cli/headless/runner.js.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.d.ts +79 -1
  23. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  24. package/dist/server/cli/headless/stall-assessor.js +355 -20
  25. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.d.ts +70 -0
  27. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
  28. package/dist/server/cli/headless/tool-watchdog.js +302 -0
  29. package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
  30. package/dist/server/cli/headless/types.d.ts +98 -1
  31. package/dist/server/cli/headless/types.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.d.ts +136 -2
  33. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-session-manager.js +929 -132
  35. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  36. package/dist/server/index.js +5 -13
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  39. package/dist/server/mcp/bouncer-integration.js +18 -0
  40. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  41. package/dist/server/mcp/security-audit.d.ts +2 -2
  42. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  43. package/dist/server/mcp/security-audit.js +12 -8
  44. package/dist/server/mcp/security-audit.js.map +1 -1
  45. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  46. package/dist/server/mcp/security-patterns.js +9 -4
  47. package/dist/server/mcp/security-patterns.js.map +1 -1
  48. package/dist/server/routes/improvise.js +6 -6
  49. package/dist/server/routes/improvise.js.map +1 -1
  50. package/dist/server/services/analytics.d.ts +2 -0
  51. package/dist/server/services/analytics.d.ts.map +1 -1
  52. package/dist/server/services/analytics.js +26 -4
  53. package/dist/server/services/analytics.js.map +1 -1
  54. package/dist/server/services/platform.d.ts.map +1 -1
  55. package/dist/server/services/platform.js +17 -10
  56. package/dist/server/services/platform.js.map +1 -1
  57. package/dist/server/services/sandbox-utils.d.ts +6 -0
  58. package/dist/server/services/sandbox-utils.d.ts.map +1 -0
  59. package/dist/server/services/sandbox-utils.js +72 -0
  60. package/dist/server/services/sandbox-utils.js.map +1 -0
  61. package/dist/server/services/settings.d.ts +6 -0
  62. package/dist/server/services/settings.d.ts.map +1 -1
  63. package/dist/server/services/settings.js +21 -0
  64. package/dist/server/services/settings.js.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.d.ts +5 -51
  66. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  67. package/dist/server/services/terminal/pty-manager.js +63 -102
  68. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  69. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  70. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  71. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  72. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  73. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  74. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  75. package/dist/server/services/websocket/git-handlers.js +797 -0
  76. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  77. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  78. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  79. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  80. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  81. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  82. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  83. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  84. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  85. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  87. package/dist/server/services/websocket/handler-context.js +4 -0
  88. package/dist/server/services/websocket/handler-context.js.map +1 -0
  89. package/dist/server/services/websocket/handler.d.ts +27 -338
  90. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  91. package/dist/server/services/websocket/handler.js +74 -2106
  92. package/dist/server/services/websocket/handler.js.map +1 -1
  93. package/dist/server/services/websocket/index.d.ts +1 -1
  94. package/dist/server/services/websocket/index.d.ts.map +1 -1
  95. package/dist/server/services/websocket/index.js.map +1 -1
  96. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  97. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  98. package/dist/server/services/websocket/session-handlers.js +507 -0
  99. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  100. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  101. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  102. package/dist/server/services/websocket/settings-handlers.js +125 -0
  103. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  104. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  105. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  106. package/dist/server/services/websocket/tab-handlers.js +131 -0
  107. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  108. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  109. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  110. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  111. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  112. package/dist/server/services/websocket/types.d.ts +67 -2
  113. package/dist/server/services/websocket/types.d.ts.map +1 -1
  114. package/hooks/bouncer.sh +11 -4
  115. package/package.json +7 -2
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +740 -133
  118. package/server/cli/headless/index.ts +7 -1
  119. package/server/cli/headless/output-utils.test.ts +225 -0
  120. package/server/cli/headless/prompt-utils.ts +37 -5
  121. package/server/cli/headless/runner.ts +55 -8
  122. package/server/cli/headless/stall-assessor.test.ts +165 -0
  123. package/server/cli/headless/stall-assessor.ts +478 -22
  124. package/server/cli/headless/tool-watchdog.test.ts +429 -0
  125. package/server/cli/headless/tool-watchdog.ts +398 -0
  126. package/server/cli/headless/types.ts +93 -1
  127. package/server/cli/improvisation-session-manager.ts +1133 -145
  128. package/server/index.ts +5 -14
  129. package/server/mcp/README.md +59 -67
  130. package/server/mcp/bouncer-integration.test.ts +161 -0
  131. package/server/mcp/bouncer-integration.ts +28 -0
  132. package/server/mcp/security-audit.ts +12 -8
  133. package/server/mcp/security-patterns.test.ts +258 -0
  134. package/server/mcp/security-patterns.ts +8 -2
  135. package/server/routes/improvise.ts +6 -6
  136. package/server/services/analytics.ts +26 -4
  137. package/server/services/platform.test.ts +0 -10
  138. package/server/services/platform.ts +16 -11
  139. package/server/services/sandbox-utils.ts +78 -0
  140. package/server/services/settings.ts +25 -0
  141. package/server/services/terminal/pty-manager.ts +68 -129
  142. package/server/services/websocket/autocomplete.test.ts +194 -0
  143. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  144. package/server/services/websocket/git-handlers.ts +924 -0
  145. package/server/services/websocket/git-pr-handlers.ts +363 -0
  146. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  147. package/server/services/websocket/handler-context.ts +44 -0
  148. package/server/services/websocket/handler.test.ts +1 -1
  149. package/server/services/websocket/handler.ts +90 -2421
  150. package/server/services/websocket/index.ts +1 -1
  151. package/server/services/websocket/session-handlers.ts +574 -0
  152. package/server/services/websocket/settings-handlers.ts +150 -0
  153. package/server/services/websocket/tab-handlers.ts +150 -0
  154. package/server/services/websocket/terminal-handlers.ts +277 -0
  155. package/server/services/websocket/types.ts +145 -4
  156. package/bin/release.sh +0 -110
  157. package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
  158. package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
  159. package/dist/server/services/terminal/tmux-manager.js +0 -352
  160. package/dist/server/services/terminal/tmux-manager.js.map +0 -1
  161. package/server/services/terminal/tmux-manager.ts +0 -426
@@ -4,84 +4,41 @@
4
4
  /**
5
5
  * WebSocket Handler for Improvisation Sessions
6
6
  *
7
- * Manages WebSocket connections for real-time improvisation sessions.
8
- * Integrates with ImprovisationSessionManager to execute Claude Code commands.
7
+ * Thin orchestrator that routes WebSocket messages to domain-specific handlers.
8
+ * Owns shared state (sessions, connections, etc.) and satisfies the HandlerContext interface.
9
9
  */
10
10
 
11
- import { spawn } from 'node:child_process';
12
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import type { ChildProcess } from 'node:child_process';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
14
  import { dirname, join } from 'node:path';
15
- import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
16
- import { AnalyticsEvents, trackEvent } from '../analytics.js';
17
- import {
18
- createDirectory,
19
- createFile,
20
- deleteFile,
21
- listDirectory,
22
- renameFile,
23
- writeFile
24
- } from '../files.js';
15
+ import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
25
16
  import { captureException } from '../sentry.js';
26
- import { getModel, getSettings, setModel } from '../settings.js';
27
- import { getPTYManager } from '../terminal/pty-manager.js';
28
17
  import { AutocompleteService } from './autocomplete.js';
29
- import { readFileContent } from './file-utils.js';
18
+ import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
19
+ import { handleGitMessage } from './git-handlers.js';
20
+ import type { HandlerContext, UsageReporter } from './handler-context.js';
21
+ import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistoricalSession } from './session-handlers.js';
30
22
  import { SessionRegistry } from './session-registry.js';
31
- import type { FrecencyData, GitDirectorySetResponse, GitFileStatus, GitLogEntry, GitRepoInfo, GitReposDiscoveredResponse, GitStatusResponse, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
23
+ import { generateNotificationSummary, handleGetSettings, handleUpdateSettings } from './settings-handlers.js';
24
+ import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncPromptText, handleSyncTabMeta } from './tab-handlers.js';
25
+ import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
26
+ import type { FrecencyData, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
32
27
 
33
- export interface UsageReport {
34
- tokensUsed: number;
35
- sessionId?: string;
36
- movementId?: string;
37
- }
38
-
39
- export type UsageReporter = (report: UsageReport) => void;
40
-
41
- /** Convert a single movement record into OutputLine-compatible entries */
42
- function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: any[]; assistantResponse?: string; errorOutput?: string; tokensUsed: number }): any[] {
43
- const lines: any[] = [];
44
- const ts = new Date(movement.timestamp).getTime();
45
-
46
- lines.push({ type: 'user', text: `> ${movement.userPrompt}`, timestamp: ts });
47
-
48
- if (movement.thinkingOutput) {
49
- lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
50
- }
51
-
52
- if (movement.toolUseHistory) {
53
- for (const tool of movement.toolUseHistory) {
54
- lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
55
- if (tool.result !== undefined) {
56
- lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
57
- }
58
- }
59
- }
60
-
61
- if (movement.assistantResponse) {
62
- lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
63
- }
64
-
65
- if (movement.errorOutput) {
66
- lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
67
- }
68
-
69
- lines.push({ type: 'system', text: `Command completed (tokens: ${movement.tokensUsed.toLocaleString()})`, timestamp: ts });
70
- return lines;
71
- }
28
+ export type { UsageReport, UsageReporter } from './handler-context.js';
72
29
 
73
- export class WebSocketImproviseHandler {
74
- private sessions: Map<string, ImprovisationSessionManager> = new Map();
75
- private connections: Map<WSContext, Map<string, string>> = new Map();
76
- private autocompleteService: AutocompleteService;
30
+ export class WebSocketImproviseHandler implements HandlerContext {
31
+ sessions: Map<string, ImprovisationSessionManager> = new Map();
32
+ connections: Map<WSContext, Map<string, string>> = new Map();
33
+ autocompleteService: AutocompleteService;
77
34
  private frecencyPath: string;
78
- private usageReporter: UsageReporter | null = null;
79
- /** Per-tab selected git directory (tabId -> directory path) */
80
- private gitDirectories: Map<string, string> = new Map();
81
- /** Persistent tab→session mapping that survives WS disconnections */
35
+ usageReporter: UsageReporter | null = null;
36
+ gitDirectories: Map<string, string> = new Map();
82
37
  private sessionRegistry: SessionRegistry | null = null;
83
- /** All connected WS contexts (for broadcasting to all clients) */
84
- private allConnections: Set<WSContext> = new Set();
38
+ allConnections: Set<WSContext> = new Set();
39
+ activeSearches: Map<string, ChildProcess> = new Map();
40
+ terminalListenerCleanups: Map<string, () => void> = new Map();
41
+ terminalSubscribers: Map<string, Set<WSContext>> = new Map();
85
42
 
86
43
  constructor() {
87
44
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
@@ -89,26 +46,17 @@ export class WebSocketImproviseHandler {
89
46
  this.autocompleteService = new AutocompleteService(frecencyData);
90
47
  }
91
48
 
92
- /**
93
- * Lazily initialize session registry for a working directory
94
- */
95
- private getRegistry(workingDir: string): SessionRegistry {
49
+ getRegistry(workingDir: string): SessionRegistry {
96
50
  if (!this.sessionRegistry) {
97
51
  this.sessionRegistry = new SessionRegistry(workingDir);
98
52
  }
99
53
  return this.sessionRegistry;
100
54
  }
101
55
 
102
- /**
103
- * Set the usage reporter callback for sending usage data to platform
104
- */
105
56
  setUsageReporter(reporter: UsageReporter): void {
106
57
  this.usageReporter = reporter;
107
58
  }
108
59
 
109
- /**
110
- * Load frecency data from disk
111
- */
112
60
  private loadFrecencyData(): FrecencyData {
113
61
  try {
114
62
  if (existsSync(this.frecencyPath)) {
@@ -121,9 +69,6 @@ export class WebSocketImproviseHandler {
121
69
  return {};
122
70
  }
123
71
 
124
- /**
125
- * Save frecency data to disk
126
- */
127
72
  private saveFrecencyData(): void {
128
73
  try {
129
74
  const dir = dirname(this.frecencyPath);
@@ -136,25 +81,16 @@ export class WebSocketImproviseHandler {
136
81
  }
137
82
  }
138
83
 
139
- /**
140
- * Record a file selection for frecency scoring
141
- */
142
84
  recordFileSelection(filePath: string): void {
143
85
  this.autocompleteService.recordFileSelection(filePath);
144
86
  this.saveFrecencyData();
145
87
  }
146
88
 
147
- /**
148
- * Handle new WebSocket connection
149
- */
150
89
  handleConnection(ws: WSContext, _workingDir: string): void {
151
90
  this.connections.set(ws, new Map());
152
91
  this.allConnections.add(ws);
153
92
  }
154
93
 
155
- /**
156
- * Handle incoming WebSocket message
157
- */
158
94
  async handleMessage(
159
95
  ws: WSContext,
160
96
  message: string,
@@ -163,8 +99,10 @@ export class WebSocketImproviseHandler {
163
99
  try {
164
100
  const msg: WebSocketMessage = JSON.parse(message);
165
101
  const tabId = msg.tabId || 'default';
102
+ const permission = msg._permission;
103
+ delete msg._permission;
166
104
 
167
- await this.dispatchMessage(ws, msg, tabId, workingDir);
105
+ await this.dispatchMessage(ws, msg, tabId, workingDir, permission);
168
106
  } catch (error: any) {
169
107
  console.error('[WebSocketImproviseHandler] Error handling message:', error);
170
108
  captureException(error, { context: 'websocket.handleMessage' });
@@ -175,47 +113,50 @@ export class WebSocketImproviseHandler {
175
113
  }
176
114
  }
177
115
 
178
- /**
179
- * Dispatch a parsed message to the appropriate handler
180
- */
181
- private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
116
+ private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): Promise<void> {
182
117
  switch (msg.type) {
183
118
  case 'ping':
184
119
  this.send(ws, { type: 'pong', tabId });
185
120
  return;
186
121
  case 'initTab':
187
- return void await this.initializeTab(ws, tabId, workingDir, msg.data?.tabName);
122
+ return void await initializeTab(this, ws, tabId, workingDir, msg.data?.tabName);
188
123
  case 'resumeSession':
189
124
  if (!msg.data?.historicalSessionId) throw new Error('Historical session ID is required');
190
- return void await this.resumeHistoricalSession(ws, tabId, workingDir, msg.data.historicalSessionId);
125
+ return void await resumeHistoricalSession(this, ws, tabId, workingDir, msg.data.historicalSessionId);
126
+ // Session messages
191
127
  case 'execute':
192
128
  case 'cancel':
193
129
  case 'getHistory':
194
130
  case 'new':
195
131
  case 'approve':
196
132
  case 'reject':
197
- return this.handleSessionMessage(ws, msg, tabId);
133
+ return handleSessionMessage(this, ws, msg, tabId, permission);
134
+ // History messages
198
135
  case 'getSessions':
199
136
  case 'getSessionsCount':
200
137
  case 'getSessionById':
201
138
  case 'deleteSession':
202
139
  case 'clearHistory':
203
140
  case 'searchHistory':
204
- return this.handleHistoryMessage(ws, msg, tabId, workingDir);
141
+ return handleHistoryMessage(this, ws, msg, tabId, workingDir);
142
+ // File autocomplete/read
205
143
  case 'autocomplete':
206
144
  case 'readFile':
207
145
  case 'recordSelection':
146
+ return handleFileMessage(this, ws, msg, tabId, workingDir, permission);
147
+ // Notification summary
208
148
  case 'requestNotificationSummary':
209
- return this.handleFileMessage(ws, msg, tabId, workingDir);
149
+ if (!msg.data?.prompt || !msg.data?.output) throw new Error('Prompt and output are required for notification summary');
150
+ return void await generateNotificationSummary(this, ws, tabId, msg.data.prompt, msg.data.output, workingDir);
151
+ // Terminal messages
210
152
  case 'terminalInit':
211
153
  case 'terminalReconnect':
212
154
  case 'terminalList':
213
- case 'terminalInitPersistent':
214
- case 'terminalListPersistent':
215
155
  case 'terminalInput':
216
156
  case 'terminalResize':
217
157
  case 'terminalClose':
218
- return this.handleTerminalMessage(ws, msg, tabId, workingDir);
158
+ return handleTerminalMessage(this, ws, msg, tabId, workingDir, permission);
159
+ // File explorer messages
219
160
  case 'listDirectory':
220
161
  case 'writeFile':
221
162
  case 'createFile':
@@ -223,623 +164,75 @@ export class WebSocketImproviseHandler {
223
164
  case 'deleteFile':
224
165
  case 'renameFile':
225
166
  case 'notifyFileOpened':
226
- return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
167
+ case 'searchFileContents':
168
+ case 'cancelSearch':
169
+ case 'findDefinition':
170
+ return handleFileExplorerMessage(this, ws, msg, tabId, workingDir, permission);
171
+ // Git messages
227
172
  case 'gitStatus':
228
173
  case 'gitStage':
229
174
  case 'gitUnstage':
230
175
  case 'gitCommit':
231
176
  case 'gitCommitWithAI':
232
177
  case 'gitPush':
178
+ case 'gitPull':
233
179
  case 'gitLog':
234
180
  case 'gitDiscoverRepos':
235
181
  case 'gitSetDirectory':
236
- return this.handleGitMessage(ws, msg, tabId, workingDir);
237
- // Session sync messages
182
+ case 'gitGetRemoteInfo':
183
+ case 'gitCreatePR':
184
+ case 'gitGeneratePRDescription':
185
+ case 'gitListBranches':
186
+ case 'gitCheckout':
187
+ case 'gitCreateBranch':
188
+ case 'gitDeleteBranch':
189
+ case 'gitDiff':
190
+ case 'gitListTags':
191
+ case 'gitCreateTag':
192
+ case 'gitPushTag':
193
+ case 'gitWorktreeList':
194
+ case 'gitWorktreeCreate':
195
+ case 'gitWorktreeRemove':
196
+ case 'tabWorktreeSwitch':
197
+ case 'gitWorktreePush':
198
+ case 'gitWorktreeCreatePR':
199
+ case 'gitMergePreview':
200
+ case 'gitWorktreeMerge':
201
+ case 'gitMergeAbort':
202
+ case 'gitMergeComplete':
203
+ return handleGitMessage(this, ws, msg, tabId, workingDir);
204
+ // Tab sync messages
238
205
  case 'getActiveTabs':
239
- return this.handleGetActiveTabs(ws, workingDir);
206
+ return handleGetActiveTabs(this, ws, workingDir);
240
207
  case 'createTab':
241
- return void await this.handleCreateTab(ws, workingDir, msg.data?.tabName, msg.data?.optimisticTabId);
208
+ return void await handleCreateTab(this, ws, workingDir, msg.data?.tabName, msg.data?.optimisticTabId);
242
209
  case 'reorderTabs':
243
- return this.handleReorderTabs(ws, workingDir, msg.data?.tabOrder);
210
+ return handleReorderTabs(this, ws, workingDir, msg.data?.tabOrder);
244
211
  case 'syncTabMeta':
245
- return this.handleSyncTabMeta(ws, msg, tabId, workingDir);
212
+ return handleSyncTabMeta(this, ws, msg, tabId, workingDir);
246
213
  case 'syncPromptText':
247
- return this.handleSyncPromptText(ws, msg, tabId);
214
+ return handleSyncPromptText(this, ws, msg, tabId);
248
215
  case 'removeTab':
249
- return this.handleRemoveTab(ws, tabId, workingDir);
216
+ return handleRemoveTab(this, ws, tabId, workingDir);
250
217
  case 'markTabViewed':
251
- return this.handleMarkTabViewed(ws, tabId, workingDir);
218
+ return handleMarkTabViewed(this, ws, tabId, workingDir);
252
219
  // Settings messages
253
220
  case 'getSettings':
254
- return this.handleGetSettings(ws);
221
+ return handleGetSettings(this, ws);
255
222
  case 'updateSettings':
256
- return this.handleUpdateSettings(ws, msg);
223
+ return handleUpdateSettings(this, ws, msg);
257
224
  default:
258
225
  throw new Error(`Unknown message type: ${msg.type}`);
259
226
  }
260
227
  }
261
228
 
262
- /**
263
- * Handle session-related messages (execute, cancel, history, new, approve, reject)
264
- */
265
- private handleSessionMessage(ws: WSContext, msg: WebSocketMessage, tabId: string): void {
266
- switch (msg.type) {
267
- case 'execute': {
268
- if (!msg.data?.prompt) throw new Error('Prompt is required');
269
- const session = this.requireSession(ws, tabId);
270
- session.executePrompt(msg.data.prompt, msg.data.attachments);
271
- break;
272
- }
273
- case 'cancel': {
274
- const session = this.requireSession(ws, tabId);
275
- session.cancel();
276
- this.send(ws, { type: 'output', tabId, data: { text: '\n⚠️ Operation cancelled\n' } });
277
- break;
278
- }
279
- case 'getHistory': {
280
- const session = this.requireSession(ws, tabId);
281
- this.send(ws, { type: 'history', tabId, data: session.getHistory() });
282
- break;
283
- }
284
- case 'new': {
285
- const oldSession = this.requireSession(ws, tabId);
286
- const newSession = oldSession.startNewSession({ model: getModel() });
287
- this.setupSessionListeners(newSession, ws, tabId);
288
- const newSessionId = newSession.getSessionInfo().sessionId;
289
- this.sessions.set(newSessionId, newSession);
290
- const tabMap = this.connections.get(ws);
291
- if (tabMap) tabMap.set(tabId, newSessionId);
292
- // Update registry with new session ID
293
- if (this.sessionRegistry) {
294
- this.sessionRegistry.updateTabSession(tabId, newSessionId);
295
- }
296
- this.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
297
- break;
298
- }
299
- case 'approve': {
300
- const session = this.requireSession(ws, tabId);
301
- (session as any).respondToApproval?.(true);
302
- this.send(ws, { type: 'output', tabId, data: { text: '\n✅ Approved - proceeding with operation\n' } });
303
- break;
304
- }
305
- case 'reject': {
306
- const session = this.requireSession(ws, tabId);
307
- (session as any).respondToApproval?.(false);
308
- this.send(ws, { type: 'output', tabId, data: { text: '\n🚫 Rejected - operation cancelled\n' } });
309
- break;
310
- }
311
- }
312
- }
313
-
314
- /**
315
- * Handle history/session listing messages
316
- */
317
- private handleHistoryMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
318
- switch (msg.type) {
319
- case 'getSessions': {
320
- const result = this.getSessionsList(workingDir, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
321
- this.send(ws, { type: 'sessions', tabId, data: result });
322
- break;
323
- }
324
- case 'getSessionsCount':
325
- this.send(ws, { type: 'sessionsCount', tabId, data: { total: this.getSessionsCount(workingDir) } });
326
- break;
327
- case 'getSessionById':
328
- if (!msg.data?.sessionId) throw new Error('Session ID is required');
329
- this.send(ws, { type: 'sessionData', tabId, data: this.getSessionById(workingDir, msg.data.sessionId) });
330
- break;
331
- case 'deleteSession':
332
- if (!msg.data?.sessionId) throw new Error('Session ID is required');
333
- this.send(ws, { type: 'sessionDeleted', tabId, data: this.deleteSession(workingDir, msg.data.sessionId) });
334
- break;
335
- case 'clearHistory':
336
- this.send(ws, { type: 'historyCleared', tabId, data: this.clearAllSessions(workingDir) });
337
- break;
338
- case 'searchHistory': {
339
- if (!msg.data?.query) throw new Error('Search query is required');
340
- const result = this.searchSessions(workingDir, msg.data.query, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
341
- this.send(ws, { type: 'searchResults', tabId, data: { ...result, query: msg.data.query } });
342
- break;
343
- }
344
- }
345
- }
346
-
347
- /**
348
- * Handle file-related messages (autocomplete, readFile, recordSelection, notifications)
349
- */
350
- private handleFileMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
351
- switch (msg.type) {
352
- case 'autocomplete':
353
- if (!msg.data?.partialPath) throw new Error('Partial path is required');
354
- this.send(ws, { type: 'autocomplete', tabId, data: { completions: this.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
355
- break;
356
- case 'readFile':
357
- if (!msg.data?.filePath) throw new Error('File path is required');
358
- this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
359
- break;
360
- case 'recordSelection':
361
- if (msg.data?.filePath) this.recordFileSelection(msg.data.filePath);
362
- break;
363
- case 'requestNotificationSummary':
364
- if (!msg.data?.prompt || !msg.data?.output) throw new Error('Prompt and output are required for notification summary');
365
- this.generateNotificationSummary(ws, tabId, msg.data.prompt, msg.data.output, workingDir);
366
- break;
367
- }
368
- }
369
-
370
- /**
371
- * Handle terminal messages
372
- */
373
- private handleTerminalMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
374
- const termId = msg.terminalId || tabId;
375
- switch (msg.type) {
376
- case 'terminalInit':
377
- this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
378
- break;
379
- case 'terminalReconnect':
380
- this.handleTerminalReconnect(ws, termId);
381
- break;
382
- case 'terminalList':
383
- this.handleTerminalList(ws);
384
- break;
385
- case 'terminalInitPersistent':
386
- this.handleTerminalInitPersistent(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
387
- break;
388
- case 'terminalListPersistent':
389
- this.handleTerminalListPersistent(ws);
390
- break;
391
- case 'terminalInput':
392
- this.handleTerminalInput(ws, termId, msg.data?.input);
393
- break;
394
- case 'terminalResize':
395
- this.handleTerminalResize(ws, termId, msg.data?.cols, msg.data?.rows);
396
- break;
397
- case 'terminalClose':
398
- this.handleTerminalClose(ws, termId);
399
- break;
400
- }
401
- }
402
-
403
- /**
404
- * Handle file explorer operations with success/error response pattern
405
- */
406
- private sendFileResult(ws: WSContext, type: WebSocketResponse['type'], tabId: string, result: any, successData?: Record<string, any>): void {
407
- const data = result.success
408
- ? { success: true, path: result.path, ...successData }
409
- : { success: false, path: result.path, error: result.error };
410
- this.send(ws, { type, tabId, data });
411
- }
412
-
413
- private handleListDirectory(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
414
- if (msg.data?.dirPath === undefined) throw new Error('Directory path is required');
415
- const result = listDirectory(msg.data.dirPath, workingDir, msg.data.showHidden ?? false);
416
- 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 } });
417
- }
418
-
419
- private handleWriteFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
420
- if (!msg.data?.filePath) throw new Error('File path is required');
421
- if (msg.data.content === undefined) throw new Error('Content is required');
422
- const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
423
- this.sendFileResult(ws, 'fileWritten', tabId, result);
424
- if (result.success) {
425
- this.broadcastToOthers(ws, {
426
- type: 'fileContentChanged',
427
- data: { path: result.path, content: msg.data.content }
428
- });
429
- }
430
- }
431
-
432
- private handleCreateFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
433
- if (!msg.data?.filePath) throw new Error('File path is required');
434
- const result = createFile(msg.data.filePath, workingDir);
435
- this.sendFileResult(ws, 'fileCreated', tabId, result);
436
- if (result.success && result.path) {
437
- const name = result.path.split('/').pop() || 'unknown';
438
- this.broadcastToOthers(ws, {
439
- type: 'fileCreated',
440
- data: { path: result.path, name, size: 0, modifiedAt: new Date().toISOString() }
441
- });
442
- }
443
- }
444
-
445
- private handleCreateDirectory(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
446
- if (!msg.data?.dirPath) throw new Error('Directory path is required');
447
- const result = createDirectory(msg.data.dirPath, workingDir);
448
- this.sendFileResult(ws, 'directoryCreated', tabId, result);
449
- if (result.success && result.path) {
450
- const name = result.path.split('/').pop() || 'unknown';
451
- this.broadcastToOthers(ws, {
452
- type: 'directoryCreated',
453
- data: { path: result.path, name }
454
- });
455
- }
456
- }
457
-
458
- private handleDeleteFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
459
- if (!msg.data?.filePath) throw new Error('File path is required');
460
- const result = deleteFile(msg.data.filePath, workingDir);
461
- this.sendFileResult(ws, 'fileDeleted', tabId, result);
462
- if (result.success && result.path) {
463
- this.broadcastToOthers(ws, {
464
- type: 'fileDeleted',
465
- data: { path: result.path }
466
- });
467
- }
468
- }
469
-
470
- private handleRenameFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
471
- if (!msg.data?.oldPath) throw new Error('Old path is required');
472
- if (!msg.data?.newPath) throw new Error('New path is required');
473
- const result = renameFile(msg.data.oldPath, msg.data.newPath, workingDir);
474
- this.sendFileResult(ws, 'fileRenamed', tabId, result);
475
- if (result.success && result.path) {
476
- const name = result.path.split('/').pop() || 'unknown';
477
- this.broadcastToOthers(ws, {
478
- type: 'fileRenamed',
479
- data: { oldPath: msg.data.oldPath, newPath: result.path, name }
480
- });
481
- }
482
- }
483
-
484
- private handleNotifyFileOpened(ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
485
- if (!msg.data?.filePath) return;
486
- const fileData = readFileContent(msg.data.filePath, workingDir);
487
- if (!fileData.error) {
488
- this.broadcastToOthers(ws, {
489
- type: 'fileOpened',
490
- data: {
491
- path: msg.data.filePath,
492
- fileName: fileData.fileName,
493
- content: fileData.content
494
- }
495
- });
496
- }
497
- }
498
-
499
- private handleFileExplorerMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
500
- const handlers: Record<string, () => void> = {
501
- listDirectory: () => this.handleListDirectory(ws, msg, tabId, workingDir),
502
- writeFile: () => this.handleWriteFile(ws, msg, tabId, workingDir),
503
- createFile: () => this.handleCreateFile(ws, msg, tabId, workingDir),
504
- createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
505
- deleteFile: () => this.handleDeleteFile(ws, msg, tabId, workingDir),
506
- renameFile: () => this.handleRenameFile(ws, msg, tabId, workingDir),
507
- notifyFileOpened: () => this.handleNotifyFileOpened(ws, msg, workingDir),
508
- };
509
- handlers[msg.type]?.();
510
- }
511
-
512
- /**
513
- * Get a session or throw
514
- */
515
- private requireSession(ws: WSContext, tabId: string): ImprovisationSessionManager {
516
- const session = this.getSession(ws, tabId);
517
- if (!session) throw new Error(`No session found for tab ${tabId}`);
518
- return session;
519
- }
520
-
521
- /**
522
- * Set up event listeners for a session
523
- */
524
- private setupSessionListeners(session: ImprovisationSessionManager, ws: WSContext, tabId: string): void {
525
- // Remove any existing listeners to prevent duplicates on reattach/reconnect
526
- session.removeAllListeners();
527
-
528
- session.on('onOutput', (text: string) => {
529
- this.send(ws, { type: 'output', tabId, data: { text, timestamp: Date.now() } });
530
- });
531
-
532
- session.on('onThinking', (text: string) => {
533
- this.send(ws, { type: 'thinking', tabId, data: { text } });
534
- });
535
-
536
- session.on('onMovementStart', (sequenceNumber: number, prompt: string) => {
537
- this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
538
- // Broadcast execution state to ALL clients so tab indicators update
539
- // even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
540
- this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true } });
541
- });
542
-
543
- session.on('onMovementComplete', (movement: any) => {
544
- this.send(ws, { type: 'movementComplete', tabId, data: movement });
545
-
546
- // Mark tab as having unviewed completion (persisted across CLI restarts)
547
- this.sessionRegistry?.markTabUnviewed(tabId);
548
-
549
- // Broadcast execution state + completion dot to ALL clients
550
- this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
551
-
552
- // Report usage to platform if reporter is configured
553
- if (this.usageReporter && movement.tokensUsed) {
554
- this.usageReporter({
555
- tokensUsed: movement.tokensUsed,
556
- sessionId: session.getSessionInfo().sessionId,
557
- movementId: `${movement.sequenceNumber}`
558
- });
559
- }
560
- });
561
-
562
- session.on('onMovementError', (error: Error) => {
563
- this.send(ws, { type: 'movementError', tabId, data: { message: error.message } });
564
- // Broadcast execution stopped to ALL clients
565
- this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
566
- });
567
-
568
- session.on('onSessionUpdate', (history: any) => {
569
- this.send(ws, { type: 'sessionUpdate', tabId, data: history });
570
- });
571
-
572
- session.on('onPlanNeedsConfirmation', (plan: any) => {
573
- this.send(ws, { type: 'approvalRequired', tabId, data: plan });
574
- });
575
-
576
- session.on('onToolUse', (event: any) => {
577
- this.send(ws, { type: 'toolUse', tabId, data: { ...event, timestamp: Date.now() } });
578
- });
579
- }
580
-
581
- /**
582
- * Resume a historical session for conversation continuity
583
- * Falls back to creating a new session if the historical session cannot be found
584
- * (e.g., server restarted before the session was saved to disk)
585
- */
586
- private async resumeHistoricalSession(
587
- ws: WSContext,
588
- tabId: string,
589
- workingDir: string,
590
- historicalSessionId: string
591
- ): Promise<void> {
592
- const tabMap = this.connections.get(ws);
593
- const registry = this.getRegistry(workingDir);
594
-
595
- // Check per-connection map first (same WS reconnect)
596
- const existingSessionId = tabMap?.get(tabId);
597
- if (existingSessionId) {
598
- const existingSession = this.sessions.get(existingSessionId);
599
- if (existingSession) {
600
- this.reattachSession(existingSession, ws, tabId, registry);
601
- return;
602
- }
603
- }
604
-
605
- // Check session registry (cross-connection reattach)
606
- const registrySessionId = registry.getTabSession(tabId);
607
- if (registrySessionId) {
608
- const inMemorySession = this.sessions.get(registrySessionId);
609
- if (inMemorySession) {
610
- this.reattachSession(inMemorySession, ws, tabId, registry);
611
- return;
612
- }
613
- }
614
-
615
- let session: ImprovisationSessionManager;
616
- let isNewSession = false;
617
-
618
- try {
619
- session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
620
- } catch (error: any) {
621
- console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
622
- session = new ImprovisationSessionManager({ workingDir, model: getModel() });
623
- isNewSession = true;
624
- }
625
-
626
- this.setupSessionListeners(session, ws, tabId);
627
-
628
- const sessionId = session.getSessionInfo().sessionId;
629
- this.sessions.set(sessionId, session);
630
-
631
- if (tabMap) {
632
- tabMap.set(tabId, sessionId);
633
- }
634
-
635
- registry.registerTab(tabId, sessionId);
636
-
637
- this.send(ws, {
638
- type: 'tabInitialized',
639
- tabId,
640
- data: {
641
- ...session.getSessionInfo(),
642
- outputHistory: this.buildOutputHistory(session),
643
- resumeFailed: isNewSession,
644
- originalSessionId: isNewSession ? historicalSessionId : undefined
645
- }
646
- });
647
- }
648
-
649
- /**
650
- * Initialize a tab with its own session.
651
- * Checks (in order): per-connection map → session registry → disk history → new session.
652
- */
653
- private async initializeTab(ws: WSContext, tabId: string, workingDir: string, tabName?: string): Promise<void> {
654
- const tabMap = this.connections.get(ws);
655
- const registry = this.getRegistry(workingDir);
656
-
657
- // 1. Check per-connection map (same WS reconnect)
658
- const existingSessionId = tabMap?.get(tabId);
659
- if (existingSessionId) {
660
- const existingSession = this.sessions.get(existingSessionId);
661
- if (existingSession) {
662
- this.reattachSession(existingSession, ws, tabId, registry);
663
- return;
664
- }
665
- }
666
-
667
- // 2. Check session registry (cross-connection reattach, e.g. browser refresh)
668
- const registrySessionId = registry.getTabSession(tabId);
669
- if (registrySessionId) {
670
- // Try in-memory first
671
- const inMemorySession = this.sessions.get(registrySessionId);
672
- if (inMemorySession) {
673
- this.reattachSession(inMemorySession, ws, tabId, registry);
674
- return;
675
- }
676
-
677
- // Try resuming from disk
678
- try {
679
- const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
680
- this.setupSessionListeners(diskSession, ws, tabId);
681
- const diskSessionId = diskSession.getSessionInfo().sessionId;
682
- this.sessions.set(diskSessionId, diskSession);
683
- if (tabMap) tabMap.set(tabId, diskSessionId);
684
- registry.touchTab(tabId);
685
-
686
- this.send(ws, {
687
- type: 'tabInitialized',
688
- tabId,
689
- data: {
690
- ...diskSession.getSessionInfo(),
691
- outputHistory: this.buildOutputHistory(diskSession),
692
- }
693
- });
694
- return;
695
- } catch {
696
- // Disk session not found — fall through to create new
697
- }
698
- }
699
-
700
- // 3. Create new session
701
- const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
702
- this.setupSessionListeners(session, ws, tabId);
703
-
704
- const sessionId = session.getSessionInfo().sessionId;
705
- this.sessions.set(sessionId, session);
706
-
707
- if (tabMap) {
708
- tabMap.set(tabId, sessionId);
709
- }
710
-
711
- registry.registerTab(tabId, sessionId, tabName);
712
- const registeredTab = registry.getTab(tabId);
713
- this.broadcastToAll({
714
- type: 'tabCreated',
715
- data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, sessionInfo: session.getSessionInfo() }
716
- });
717
-
718
- this.send(ws, {
719
- type: 'tabInitialized',
720
- tabId,
721
- data: session.getSessionInfo()
722
- });
723
- }
724
-
725
- /**
726
- * Reattach to an existing in-memory session.
727
- * Sends output history (completed movements + in-progress events) for state restoration.
728
- */
729
- private reattachSession(
730
- session: ImprovisationSessionManager,
731
- ws: WSContext,
732
- tabId: string,
733
- registry: SessionRegistry
734
- ): void {
735
- this.setupSessionListeners(session, ws, tabId);
736
-
737
- const tabMap = this.connections.get(ws);
738
- const sessionId = session.getSessionInfo().sessionId;
739
- if (tabMap) tabMap.set(tabId, sessionId);
740
- registry.touchTab(tabId);
741
-
742
- // Build output history from completed movements
743
- const outputHistory = this.buildOutputHistory(session);
744
-
745
- // If currently executing, append in-progress events
746
- const executionEvents = session.isExecuting
747
- ? session.getExecutionEventLog()
748
- : undefined;
749
-
750
- this.send(ws, {
751
- type: 'tabInitialized',
752
- tabId,
753
- data: {
754
- ...session.getSessionInfo(),
755
- outputHistory,
756
- isExecuting: session.isExecuting,
757
- executionEvents,
758
- }
759
- });
760
- }
761
-
762
- /**
763
- * Build OutputLine-compatible history from a session's completed movements.
764
- * Converts MovementRecords into the same format the web client uses for display.
765
- */
766
- private buildOutputHistory(session: ImprovisationSessionManager): any[] {
767
- const history = session.getHistory();
768
- return history.movements.flatMap(convertMovementToLines);
769
- }
770
-
771
- /**
772
- * Send a message to all connected clients EXCEPT the sender.
773
- * Used for multi-client sync (e.g., tab created by one client, others should know).
774
- */
775
- private broadcastToOthers(sender: WSContext, response: WebSocketResponse): void {
776
- for (const ws of this.allConnections) {
777
- if (ws !== sender) {
778
- this.send(ws, response);
779
- }
780
- }
781
- }
782
-
783
- /**
784
- * Send a message to ALL connected clients (including sender).
785
- */
786
- private broadcastToAll(response: WebSocketResponse): void {
787
- for (const ws of this.allConnections) {
788
- this.send(ws, response);
789
- }
790
- }
791
-
792
- // ========== Settings Handlers ==========
793
-
794
- /**
795
- * Return current machine-wide settings to the requesting client.
796
- */
797
- private handleGetSettings(ws: WSContext): void {
798
- this.send(ws, { type: 'settings', data: getSettings() });
799
- }
800
-
801
- /**
802
- * Update settings and broadcast to all connected clients.
803
- */
804
- private handleUpdateSettings(_ws: WSContext, msg: WebSocketMessage): void {
805
- if (msg.data?.model !== undefined) {
806
- setModel(msg.data.model);
807
- }
808
- this.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
809
- }
810
-
811
- /**
812
- * Get session for a specific tab
813
- */
814
- private getSession(ws: WSContext, tabId: string): ImprovisationSessionManager | null {
815
- const tabMap = this.connections.get(ws);
816
- if (!tabMap) return null;
817
-
818
- const sessionId = tabMap.get(tabId);
819
- if (!sessionId) return null;
820
-
821
- return this.sessions.get(sessionId) || null;
822
- }
823
-
824
- /**
825
- * Handle connection close
826
- * Note: Sessions are NOT destroyed — they persist for reconnection.
827
- * Only the per-connection tab mapping is removed.
828
- */
829
229
  handleClose(ws: WSContext): void {
830
230
  this.connections.delete(ws);
831
231
  this.allConnections.delete(ws);
832
-
833
- // Remove ws from all terminal subscriber sets
834
- for (const subs of this.terminalSubscribers.values()) {
835
- subs.delete(ws);
836
- }
232
+ cleanupTerminalSubscribers(this, ws);
837
233
  }
838
234
 
839
- /**
840
- * Send message to WebSocket client
841
- */
842
- private send(ws: WSContext, response: WebSocketResponse): void {
235
+ send(ws: WSContext, response: WebSocketResponse): void {
843
236
  try {
844
237
  ws.send(JSON.stringify(response));
845
238
  } catch (error) {
@@ -847,1748 +240,24 @@ export class WebSocketImproviseHandler {
847
240
  }
848
241
  }
849
242
 
850
- /**
851
- * Get count of all historical sessions without reading file contents
852
- */
853
- private getSessionsCount(workingDir: string): number {
854
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
855
-
856
- if (!existsSync(sessionsDir)) {
857
- return 0;
858
- }
859
-
860
- return readdirSync(sessionsDir)
861
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'))
862
- .length;
863
- }
864
-
865
- /**
866
- * Get paginated list of historical sessions from disk
867
- * Returns minimal metadata - movements are stripped to just userPrompt preview
868
- */
869
- private getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
870
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
871
-
872
- if (!existsSync(sessionsDir)) {
873
- return { sessions: [], total: 0, hasMore: false };
874
- }
875
-
876
- // Get sorted file list (newest first) without reading contents
877
- const historyFiles = readdirSync(sessionsDir)
878
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'))
879
- .sort((a: string, b: string) => {
880
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
881
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
882
- return timestampB - timestampA;
883
- });
884
-
885
- const total = historyFiles.length;
886
-
887
- // Only read the files we need for this page
888
- const pageFiles = historyFiles.slice(offset, offset + limit);
889
-
890
- const sessions = pageFiles.map((filename: string) => {
891
- const historyPath = join(sessionsDir, filename);
892
- try {
893
- const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
894
- const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
895
-
896
- // Return minimal metadata - only prompt previews, not full movement data
897
- const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: any) => ({
898
- userPrompt: m.userPrompt?.slice(0, 100) || ''
899
- }));
900
-
901
- return {
902
- sessionId: historyData.sessionId,
903
- startedAt: historyData.startedAt,
904
- lastActivityAt: historyData.lastActivityAt,
905
- totalTokens: historyData.totalTokens,
906
- movementCount: historyData.movements?.length || 0,
907
- title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
908
- movements: movementPreviews
909
- };
910
- } catch {
911
- return null;
912
- }
913
- }).filter(Boolean);
914
-
915
- return {
916
- sessions,
917
- total,
918
- hasMore: offset + limit < total
919
- };
920
- }
921
-
922
- /**
923
- * Get a full session by ID (includes all movement data)
924
- */
925
- private getSessionById(workingDir: string, sessionId: string): any {
926
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
927
-
928
- if (!existsSync(sessionsDir)) {
929
- return null;
930
- }
931
-
932
- const historyFiles = readdirSync(sessionsDir)
933
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'));
934
-
935
- for (const filename of historyFiles) {
936
- const historyPath = join(sessionsDir, filename);
937
- try {
938
- const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
939
- if (historyData.sessionId === sessionId) {
940
- const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
941
- return {
942
- sessionId: historyData.sessionId,
943
- startedAt: historyData.startedAt,
944
- lastActivityAt: historyData.lastActivityAt,
945
- totalTokens: historyData.totalTokens,
946
- movementCount: historyData.movements?.length || 0,
947
- title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
948
- movements: historyData.movements || [],
949
- };
950
- }
951
- } catch {
952
- // Skip files that can't be parsed
953
- }
954
- }
955
-
956
- return null;
957
- }
958
-
959
- /**
960
- * Delete a single session from disk
961
- */
962
- private deleteSession(workingDir: string, sessionId: string): { sessionId: string; success: boolean } {
963
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
964
-
965
- if (!existsSync(sessionsDir)) {
966
- return { sessionId, success: false };
967
- }
968
-
969
- try {
970
- const historyFiles = readdirSync(sessionsDir)
971
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'));
972
-
973
- for (const filename of historyFiles) {
974
- const historyPath = join(sessionsDir, filename);
975
- try {
976
- const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
977
- if (historyData.sessionId === sessionId) {
978
- unlinkSync(historyPath);
979
- return { sessionId, success: true };
980
- }
981
- } catch {
982
- // Skip files that can't be parsed
983
- }
984
- }
985
-
986
- return { sessionId, success: false };
987
- } catch (error) {
988
- console.error('[WebSocketImproviseHandler] Error deleting session:', error);
989
- return { sessionId, success: false };
990
- }
991
- }
992
-
993
- /**
994
- * Clear all sessions from disk
995
- */
996
- private clearAllSessions(workingDir: string): { success: boolean; deletedCount: number } {
997
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
998
-
999
- if (!existsSync(sessionsDir)) {
1000
- return { success: true, deletedCount: 0 };
1001
- }
1002
-
1003
- try {
1004
- const historyFiles = readdirSync(sessionsDir)
1005
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'));
1006
-
1007
- let deletedCount = 0;
1008
- for (const filename of historyFiles) {
1009
- const historyPath = join(sessionsDir, filename);
1010
- try {
1011
- unlinkSync(historyPath);
1012
- deletedCount++;
1013
- } catch {
1014
- // Skip files that can't be deleted
1015
- }
243
+ broadcastToOthers(sender: WSContext, response: WebSocketResponse): void {
244
+ for (const ws of this.allConnections) {
245
+ if (ws !== sender) {
246
+ this.send(ws, response);
1016
247
  }
1017
-
1018
- return { success: true, deletedCount };
1019
- } catch (error) {
1020
- console.error('[WebSocketImproviseHandler] Error clearing sessions:', error);
1021
- return { success: false, deletedCount: 0 };
1022
248
  }
1023
249
  }
1024
250
 
1025
- /**
1026
- * Search sessions using grep on the history directory
1027
- * Searches through session file contents for matching text
1028
- * Returns paginated results with minimal metadata
1029
- */
1030
- private movementMatchesQuery(movements: any[] | undefined, lowerQuery: string): boolean {
1031
- if (!movements) return false;
1032
- return movements.some((m: any) =>
1033
- m.userPrompt?.toLowerCase().includes(lowerQuery) ||
1034
- m.summary?.toLowerCase().includes(lowerQuery) ||
1035
- m.assistantResponse?.toLowerCase().includes(lowerQuery)
1036
- );
1037
- }
1038
-
1039
- private buildSessionSummary(historyData: any): any {
1040
- const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
1041
- const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: any) => ({
1042
- userPrompt: m.userPrompt?.slice(0, 100) || ''
1043
- }));
1044
- return {
1045
- sessionId: historyData.sessionId,
1046
- startedAt: historyData.startedAt,
1047
- lastActivityAt: historyData.lastActivityAt,
1048
- totalTokens: historyData.totalTokens,
1049
- movementCount: historyData.movements?.length || 0,
1050
- title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
1051
- movements: movementPreviews
1052
- };
1053
- }
1054
-
1055
- private searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
1056
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
1057
-
1058
- if (!existsSync(sessionsDir)) {
1059
- return { sessions: [], total: 0, hasMore: false };
1060
- }
1061
-
1062
- const lowerQuery = query.toLowerCase();
1063
-
1064
- try {
1065
- const historyFiles = readdirSync(sessionsDir)
1066
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'))
1067
- .sort((a: string, b: string) => {
1068
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
1069
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
1070
- return timestampB - timestampA;
1071
- });
1072
-
1073
- const allMatches: any[] = [];
1074
- for (const filename of historyFiles) {
1075
- try {
1076
- const content = readFileSync(join(sessionsDir, filename), 'utf-8');
1077
- const historyData = JSON.parse(content);
1078
- if (this.movementMatchesQuery(historyData.movements, lowerQuery)) {
1079
- allMatches.push(this.buildSessionSummary(historyData));
1080
- }
1081
- } catch {
1082
- // Skip files that can't be parsed
1083
- }
1084
- }
1085
-
1086
- const total = allMatches.length;
1087
- return {
1088
- sessions: allMatches.slice(offset, offset + limit),
1089
- total,
1090
- hasMore: offset + limit < total
1091
- };
1092
- } catch (error) {
1093
- console.error('[WebSocketImproviseHandler] Error searching sessions:', error);
1094
- return { sessions: [], total: 0, hasMore: false };
251
+ broadcastToAll(response: WebSocketResponse): void {
252
+ for (const ws of this.allConnections) {
253
+ this.send(ws, response);
1095
254
  }
1096
255
  }
1097
256
 
1098
- /**
1099
- * Cleanup session
1100
- */
1101
257
  cleanupSession(sessionId: string): void {
1102
258
  this.sessions.delete(sessionId);
1103
259
  }
1104
260
 
1105
- /**
1106
- * Clean up stale sessions
1107
- */
1108
261
  cleanupStaleSessions(): void {
1109
262
  }
1110
-
1111
- // ============================================
1112
- // Session sync methods
1113
- // ============================================
1114
-
1115
- /**
1116
- * Handle getActiveTabs — returns all registered tabs and their state.
1117
- * Used by new clients (multi-device, multi-browser) to discover existing tabs.
1118
- */
1119
- private handleGetActiveTabs(ws: WSContext, workingDir: string): void {
1120
- const registry = this.getRegistry(workingDir);
1121
- const allTabs = registry.getAllTabs();
1122
-
1123
- const tabs: Record<string, any> = {};
1124
- for (const [tabId, regTab] of Object.entries(allTabs)) {
1125
- const session = this.sessions.get(regTab.sessionId);
1126
- if (session) {
1127
- tabs[tabId] = {
1128
- tabName: regTab.tabName,
1129
- createdAt: regTab.createdAt,
1130
- order: regTab.order,
1131
- hasUnviewedCompletion: regTab.hasUnviewedCompletion,
1132
- sessionInfo: session.getSessionInfo(),
1133
- isExecuting: session.isExecuting,
1134
- outputHistory: this.buildOutputHistory(session),
1135
- executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
1136
- };
1137
- } else {
1138
- // Session not in memory — try to provide basic info from registry
1139
- tabs[tabId] = {
1140
- tabName: regTab.tabName,
1141
- createdAt: regTab.createdAt,
1142
- order: regTab.order,
1143
- hasUnviewedCompletion: regTab.hasUnviewedCompletion,
1144
- sessionId: regTab.sessionId,
1145
- isExecuting: false,
1146
- outputHistory: [],
1147
- };
1148
- }
1149
- }
1150
-
1151
- this.send(ws, { type: 'activeTabs', data: { tabs } });
1152
- }
1153
-
1154
- /**
1155
- * Handle syncTabMeta — update tab metadata (name) from a client.
1156
- */
1157
- private handleSyncTabMeta(_ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
1158
- const registry = this.getRegistry(workingDir);
1159
- if (msg.data?.tabName) {
1160
- registry.updateTabName(tabId, msg.data.tabName);
1161
- // Broadcast rename to all clients (relay handles fan-out)
1162
- this.broadcastToAll({
1163
- type: 'tabRenamed',
1164
- data: { tabId, tabName: msg.data.tabName }
1165
- });
1166
- }
1167
- }
1168
-
1169
- /**
1170
- * Handle syncPromptText — relay prompt text changes to all clients.
1171
- * Ephemeral: not persisted, just broadcast for live collaboration.
1172
- */
1173
- private handleSyncPromptText(_ws: WSContext, msg: WebSocketMessage, tabId: string): void {
1174
- if (typeof msg.data?.text !== 'string') return;
1175
- this.broadcastToAll({
1176
- type: 'promptTextSync',
1177
- tabId,
1178
- data: { tabId, text: msg.data.text }
1179
- });
1180
- }
1181
-
1182
- /**
1183
- * Handle removeTab — client is removing a tab.
1184
- */
1185
- private handleRemoveTab(_ws: WSContext, tabId: string, workingDir: string): void {
1186
- const registry = this.getRegistry(workingDir);
1187
- registry.unregisterTab(tabId);
1188
-
1189
- // Broadcast to all clients (broadcastToAll ensures relay-connected clients receive it)
1190
- this.broadcastToAll({
1191
- type: 'tabRemoved',
1192
- data: { tabId }
1193
- });
1194
- }
1195
-
1196
- /**
1197
- * Handle markTabViewed — a client has viewed a tab's completed output.
1198
- * Persists viewed state and broadcasts to all clients so the green dot
1199
- * disappears on every device.
1200
- */
1201
- private handleMarkTabViewed(_ws: WSContext, tabId: string, workingDir: string): void {
1202
- const registry = this.getRegistry(workingDir);
1203
- registry.markTabViewed(tabId);
1204
-
1205
- this.broadcastToAll({
1206
- type: 'tabViewed',
1207
- data: { tabId }
1208
- });
1209
- }
1210
-
1211
- /**
1212
- * Handle createTab — CLI registers the tab and broadcasts to all clients.
1213
- *
1214
- * When optimisticTabId is provided, CLI reuses that ID as the authoritative tab ID.
1215
- * The requesting client already created a local tab with this ID (optimistic UI),
1216
- * so there's no reconciliation needed — the tab ID is the same everywhere.
1217
- * The initTab flow (useTabInit) will handle session creation for the requesting client.
1218
- *
1219
- * Other clients that don't have this tab will add it via the tabCreated broadcast.
1220
- */
1221
- private async handleCreateTab(ws: WSContext, workingDir: string, tabName?: string, optimisticTabId?: string): Promise<void> {
1222
- const registry = this.getRegistry(workingDir);
1223
-
1224
- // Use the client's optimistic ID when available — avoids reconciliation.
1225
- // Fall back to server-generated ID if no optimistic ID provided.
1226
- const tabId = optimisticTabId || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1227
-
1228
- // Check if this tab was already registered by initTab (race: useTabInit fires first)
1229
- const existingSession = registry.getTabSession(tabId);
1230
- if (existingSession) {
1231
- // Tab already initialized — broadcast to all clients.
1232
- // Must use broadcastToAll because all web clients share a single
1233
- // platformRelayContext — broadcastToOthers would skip the relay entirely,
1234
- // preventing other browser instances from discovering the new tab.
1235
- const regTab = registry.getTab(tabId);
1236
- this.broadcastToAll({
1237
- type: 'tabCreated',
1238
- data: {
1239
- tabId,
1240
- tabName: regTab?.tabName || 'Chat',
1241
- createdAt: regTab?.createdAt,
1242
- order: regTab?.order,
1243
- sessionInfo: this.sessions.get(existingSession)?.getSessionInfo(),
1244
- }
1245
- });
1246
- return;
1247
- }
1248
-
1249
- // Create new session and register
1250
- const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
1251
- this.setupSessionListeners(session, ws, tabId);
1252
-
1253
- const sessionId = session.getSessionInfo().sessionId;
1254
- this.sessions.set(sessionId, session);
1255
-
1256
- const tabMap = this.connections.get(ws);
1257
- if (tabMap) tabMap.set(tabId, sessionId);
1258
-
1259
- registry.registerTab(tabId, sessionId, tabName);
1260
- const registeredTab = registry.getTab(tabId);
1261
-
1262
- // Broadcast to ALL clients — the requesting client already has the tab
1263
- // (optimistic UI) and will ignore the duplicate via !currentTabs.has(tabId).
1264
- // Must use broadcastToAll so other browser instances via the shared
1265
- // platformRelayContext receive the tabCreated event.
1266
- this.broadcastToAll({
1267
- type: 'tabCreated',
1268
- data: {
1269
- tabId,
1270
- tabName: registeredTab?.tabName || 'Chat',
1271
- createdAt: registeredTab?.createdAt,
1272
- order: registeredTab?.order,
1273
- sessionInfo: session.getSessionInfo(),
1274
- }
1275
- });
1276
-
1277
- // Send tabInitialized to the requesting client so useTabInit resolves
1278
- this.send(ws, {
1279
- type: 'tabInitialized',
1280
- tabId,
1281
- data: session.getSessionInfo()
1282
- });
1283
- }
1284
-
1285
- /**
1286
- * Handle reorderTabs — client is reordering tabs.
1287
- */
1288
- private handleReorderTabs(_ws: WSContext, workingDir: string, tabOrder?: string[]): void {
1289
- if (!Array.isArray(tabOrder)) return;
1290
- const registry = this.getRegistry(workingDir);
1291
- registry.reorderTabs(tabOrder);
1292
-
1293
- // Build order mapping for broadcast
1294
- const allTabs = registry.getAllTabs();
1295
- const orderMap = tabOrder
1296
- .filter((id) => allTabs[id])
1297
- .map((id) => ({ tabId: id, order: allTabs[id].order }));
1298
-
1299
- this.broadcastToAll({
1300
- type: 'tabsReordered',
1301
- data: { tabOrder: orderMap }
1302
- });
1303
- }
1304
-
1305
- /**
1306
- * Generate a notification summary using Claude Haiku
1307
- * Sends the result as a notificationSummary message
1308
- */
1309
- private async generateNotificationSummary(
1310
- ws: WSContext,
1311
- tabId: string,
1312
- userPrompt: string,
1313
- output: string,
1314
- workingDir: string
1315
- ): Promise<void> {
1316
- try {
1317
- // Create temp directory if it doesn't exist
1318
- const tempDir = join(workingDir, '.mstro', 'tmp');
1319
- if (!existsSync(tempDir)) {
1320
- mkdirSync(tempDir, { recursive: true });
1321
- }
1322
-
1323
- // Truncate output if too long (keep first and last parts for context)
1324
- let truncatedOutput = output;
1325
- if (output.length > 4000) {
1326
- const firstPart = output.slice(0, 2000);
1327
- const lastPart = output.slice(-1500);
1328
- truncatedOutput = `${firstPart}\n\n... [output truncated] ...\n\n${lastPart}`;
1329
- }
1330
-
1331
- // Build the prompt for summary generation
1332
- const summaryPrompt = `You are generating a SHORT browser notification summary for a completed task.
1333
- The user ran a task and wants a brief notification to remind them what happened.
1334
-
1335
- USER'S ORIGINAL PROMPT:
1336
- "${userPrompt}"
1337
-
1338
- TASK OUTPUT (may be truncated):
1339
- ${truncatedOutput}
1340
-
1341
- Generate a notification summary following these rules:
1342
- 1. Maximum 100 characters (this is a browser notification)
1343
- 2. Focus on the OUTCOME, not the process
1344
- 3. Be specific about what was accomplished
1345
- 4. Use past tense (e.g., "Fixed bug in auth.ts", "Added 3 new tests")
1346
- 5. If there was an error, mention it briefly
1347
- 6. No emojis, no markdown, just plain text
1348
-
1349
- Respond with ONLY the summary text, nothing else.`;
1350
-
1351
- // Write prompt to temp file
1352
- const promptFile = join(tempDir, `notif-summary-${Date.now()}.txt`);
1353
- writeFileSync(promptFile, summaryPrompt);
1354
-
1355
- const systemPrompt = 'You are a notification summary assistant. Respond with only the summary text, no preamble or explanation.';
1356
-
1357
- const args = [
1358
- '--print',
1359
- '--model', 'haiku',
1360
- '--system-prompt', systemPrompt,
1361
- promptFile
1362
- ];
1363
-
1364
- const claude = spawn('claude', args, {
1365
- cwd: workingDir,
1366
- stdio: ['ignore', 'pipe', 'pipe']
1367
- });
1368
-
1369
- let stdout = '';
1370
- let stderr = '';
1371
-
1372
- claude.stdout?.on('data', (data: Buffer) => {
1373
- stdout += data.toString();
1374
- });
1375
-
1376
- claude.stderr?.on('data', (data: Buffer) => {
1377
- stderr += data.toString();
1378
- });
1379
-
1380
- claude.on('close', (code: number | null) => {
1381
- // Clean up temp file
1382
- try {
1383
- unlinkSync(promptFile);
1384
- } catch {
1385
- // Ignore cleanup errors
1386
- }
1387
-
1388
- let summary: string;
1389
- if (code === 0 && stdout.trim()) {
1390
- // Truncate if somehow still too long
1391
- summary = stdout.trim().slice(0, 150);
1392
- } else {
1393
- console.error('[WebSocketImproviseHandler] Claude error:', stderr || 'Unknown error');
1394
- // Fallback to basic summary
1395
- summary = this.createFallbackSummary(userPrompt);
1396
- }
1397
-
1398
- this.send(ws, {
1399
- type: 'notificationSummary',
1400
- tabId,
1401
- data: { summary }
1402
- });
1403
- });
1404
-
1405
- claude.on('error', (err: Error) => {
1406
- console.error('[WebSocketImproviseHandler] Failed to spawn Claude:', err);
1407
- const summary = this.createFallbackSummary(userPrompt);
1408
- this.send(ws, {
1409
- type: 'notificationSummary',
1410
- tabId,
1411
- data: { summary }
1412
- });
1413
- });
1414
-
1415
- // Timeout after 10 seconds
1416
- setTimeout(() => {
1417
- claude.kill();
1418
- const summary = this.createFallbackSummary(userPrompt);
1419
- this.send(ws, {
1420
- type: 'notificationSummary',
1421
- tabId,
1422
- data: { summary }
1423
- });
1424
- }, 10000);
1425
-
1426
- } catch (error) {
1427
- console.error('[WebSocketImproviseHandler] Error generating summary:', error);
1428
- const summary = this.createFallbackSummary(userPrompt);
1429
- this.send(ws, {
1430
- type: 'notificationSummary',
1431
- tabId,
1432
- data: { summary }
1433
- });
1434
- }
1435
- }
1436
-
1437
- /**
1438
- * Create a fallback summary when AI summarization fails
1439
- */
1440
- private createFallbackSummary(userPrompt: string): string {
1441
- const truncated = userPrompt.slice(0, 60);
1442
- if (userPrompt.length > 60) {
1443
- return `Completed: "${truncated}..."`;
1444
- }
1445
- return `Completed: "${truncated}"`;
1446
- }
1447
-
1448
- // ============================================
1449
- // Git handling methods
1450
- // ============================================
1451
-
1452
- /**
1453
- * Handle git-related messages
1454
- */
1455
- private handleGitMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
1456
- // Get the effective git directory (selected or working dir)
1457
- const gitDir = this.gitDirectories.get(tabId) || workingDir;
1458
-
1459
- const handlers: Record<string, () => void> = {
1460
- gitStatus: () => this.handleGitStatus(ws, tabId, gitDir),
1461
- gitStage: () => this.handleGitStage(ws, msg, tabId, gitDir),
1462
- gitUnstage: () => this.handleGitUnstage(ws, msg, tabId, gitDir),
1463
- gitCommit: () => this.handleGitCommit(ws, msg, tabId, gitDir),
1464
- gitCommitWithAI: () => this.handleGitCommitWithAI(ws, msg, tabId, gitDir),
1465
- gitPush: () => this.handleGitPush(ws, tabId, gitDir),
1466
- gitLog: () => this.handleGitLog(ws, msg, tabId, gitDir),
1467
- gitDiscoverRepos: () => this.handleGitDiscoverRepos(ws, tabId, workingDir),
1468
- gitSetDirectory: () => this.handleGitSetDirectory(ws, msg, tabId, workingDir),
1469
- };
1470
- handlers[msg.type]?.();
1471
- }
1472
-
1473
- /**
1474
- * Execute a git command and return stdout
1475
- */
1476
- private executeGitCommand(args: string[], workingDir: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
1477
- return new Promise((resolve) => {
1478
- const git = spawn('git', args, {
1479
- cwd: workingDir,
1480
- stdio: ['ignore', 'pipe', 'pipe']
1481
- });
1482
-
1483
- let stdout = '';
1484
- let stderr = '';
1485
-
1486
- git.stdout?.on('data', (data: Buffer) => {
1487
- stdout += data.toString();
1488
- });
1489
-
1490
- git.stderr?.on('data', (data: Buffer) => {
1491
- stderr += data.toString();
1492
- });
1493
-
1494
- git.on('close', (code: number | null) => {
1495
- resolve({ stdout, stderr, exitCode: code ?? 1 });
1496
- });
1497
-
1498
- git.on('error', (err: Error) => {
1499
- resolve({ stdout: '', stderr: err.message, exitCode: 1 });
1500
- });
1501
- });
1502
- }
1503
-
1504
- /** Map of simple escape sequences to their character values */
1505
- private static readonly ESCAPE_CHARS: Record<string, string> = {
1506
- '\\': '\\',
1507
- '"': '"',
1508
- 'n': '\n',
1509
- 't': '\t',
1510
- 'r': '\r',
1511
- };
1512
-
1513
- /**
1514
- * Unquote a git-quoted path (C-style quoting)
1515
- * Git quotes paths containing spaces, special chars, or non-ASCII with double quotes
1516
- * and uses backslash escapes inside (e.g., \", \\, \n, \t, \nnn for octal)
1517
- */
1518
- private unquoteGitPath(path: string): string {
1519
- // If not quoted, return as-is
1520
- if (!path.startsWith('"') || !path.endsWith('"')) {
1521
- return path;
1522
- }
1523
-
1524
- // Remove surrounding quotes and process escape sequences
1525
- const inner = path.slice(1, -1);
1526
- let result = '';
1527
- let i = 0;
1528
-
1529
- while (i < inner.length) {
1530
- if (inner[i] !== '\\' || i + 1 >= inner.length) {
1531
- result += inner[i];
1532
- i++;
1533
- continue;
1534
- }
1535
-
1536
- const next = inner[i + 1];
1537
- const escaped = WebSocketImproviseHandler.ESCAPE_CHARS[next];
1538
-
1539
- if (escaped !== undefined) {
1540
- result += escaped;
1541
- i += 2;
1542
- } else if (this.isOctalEscape(inner, i)) {
1543
- result += String.fromCharCode(parseInt(inner.slice(i + 1, i + 4), 8));
1544
- i += 4;
1545
- } else {
1546
- result += inner[i];
1547
- i++;
1548
- }
1549
- }
1550
-
1551
- return result;
1552
- }
1553
-
1554
- /** Check if position i starts an octal escape sequence (\nnn) */
1555
- private isOctalEscape(str: string, i: number): boolean {
1556
- return i + 3 < str.length &&
1557
- /[0-7]/.test(str[i + 1]) &&
1558
- /[0-7]{2}/.test(str.slice(i + 2, i + 4));
1559
- }
1560
-
1561
- /**
1562
- * Parse git status --porcelain output into structured format
1563
- */
1564
- private parseGitStatus(porcelainOutput: string): { staged: GitFileStatus[]; unstaged: GitFileStatus[]; untracked: GitFileStatus[] } {
1565
- const staged: GitFileStatus[] = [];
1566
- const unstaged: GitFileStatus[] = [];
1567
- const untracked: GitFileStatus[] = [];
1568
-
1569
- const lines = porcelainOutput.split('\n').filter(line => line.length >= 4);
1570
-
1571
- for (const line of lines) {
1572
-
1573
- const indexStatus = line[0];
1574
- const workTreeStatus = line[1];
1575
- const rawPath = line.slice(3);
1576
-
1577
- // Unquote the path (git quotes paths with spaces/special chars)
1578
- const path = this.unquoteGitPath(rawPath);
1579
-
1580
- // Handle renamed files (format: "R old -> new" or R "old" -> "new")
1581
- let filePath = path;
1582
- let originalPath: string | undefined;
1583
- if (rawPath.includes(' -> ')) {
1584
- const parts = rawPath.split(' -> ');
1585
- originalPath = this.unquoteGitPath(parts[0]);
1586
- filePath = this.unquoteGitPath(parts[1]);
1587
- }
1588
-
1589
- // Untracked files
1590
- if (indexStatus === '?' && workTreeStatus === '?') {
1591
- untracked.push({
1592
- path: filePath,
1593
- status: '?',
1594
- staged: false,
1595
- });
1596
- continue;
1597
- }
1598
-
1599
- // Staged changes (index has changes)
1600
- if (indexStatus !== ' ' && indexStatus !== '?') {
1601
- staged.push({
1602
- path: filePath,
1603
- status: indexStatus as GitFileStatus['status'],
1604
- staged: true,
1605
- originalPath,
1606
- });
1607
- }
1608
-
1609
- // Unstaged changes (worktree has changes)
1610
- if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
1611
- unstaged.push({
1612
- path: filePath,
1613
- status: workTreeStatus as GitFileStatus['status'],
1614
- staged: false,
1615
- originalPath,
1616
- });
1617
- }
1618
- }
1619
-
1620
- return { staged, unstaged, untracked };
1621
- }
1622
-
1623
- /**
1624
- * Handle git status request
1625
- */
1626
- private async handleGitStatus(ws: WSContext, tabId: string, workingDir: string): Promise<void> {
1627
- try {
1628
- // Get porcelain status
1629
- const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1630
- if (statusResult.exitCode !== 0) {
1631
- this.send(ws, { type: 'gitError', tabId, data: { error: statusResult.stderr || statusResult.stdout || 'Failed to get git status' } });
1632
- return;
1633
- }
1634
-
1635
- // Get current branch
1636
- const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
1637
- const branch = branchResult.stdout.trim() || 'HEAD';
1638
-
1639
- // Get ahead/behind counts
1640
- let ahead = 0;
1641
- let behind = 0;
1642
- const trackingResult = await this.executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
1643
- if (trackingResult.exitCode === 0) {
1644
- const parts = trackingResult.stdout.trim().split(/\s+/);
1645
- ahead = parseInt(parts[0], 10) || 0;
1646
- behind = parseInt(parts[1], 10) || 0;
1647
- }
1648
-
1649
- const { staged, unstaged, untracked } = this.parseGitStatus(statusResult.stdout);
1650
-
1651
- const response: GitStatusResponse = {
1652
- branch,
1653
- isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0,
1654
- staged,
1655
- unstaged,
1656
- untracked,
1657
- ahead,
1658
- behind,
1659
- };
1660
-
1661
- this.send(ws, { type: 'gitStatus', tabId, data: response });
1662
- } catch (error: any) {
1663
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1664
- }
1665
- }
1666
-
1667
- /**
1668
- * Handle git stage request
1669
- */
1670
- private async handleGitStage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
1671
- const stageAll = !!msg.data?.stageAll;
1672
- const paths = msg.data?.paths as string[] | undefined;
1673
-
1674
- if (!stageAll && (!paths || paths.length === 0)) {
1675
- this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for staging' } });
1676
- return;
1677
- }
1678
-
1679
- try {
1680
- // Use `git add -A` for staging all (handles new, modified, and deleted files reliably)
1681
- // Use `git add -- ...paths` for staging specific files
1682
- const args = stageAll ? ['add', '-A'] : ['add', '--', ...paths!];
1683
- const result = await this.executeGitCommand(args, workingDir);
1684
- if (result.exitCode !== 0) {
1685
- this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to stage files' } });
1686
- return;
1687
- }
1688
-
1689
- this.send(ws, { type: 'gitStaged', tabId, data: { paths: paths || [] } });
1690
- } catch (error: any) {
1691
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1692
- }
1693
- }
1694
-
1695
- /**
1696
- * Handle git unstage request
1697
- */
1698
- private async handleGitUnstage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
1699
- const paths = msg.data?.paths as string[] | undefined;
1700
- if (!paths || paths.length === 0) {
1701
- this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for unstaging' } });
1702
- return;
1703
- }
1704
-
1705
- try {
1706
- const result = await this.executeGitCommand(['reset', 'HEAD', '--', ...paths], workingDir);
1707
- if (result.exitCode !== 0) {
1708
- this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to unstage files' } });
1709
- return;
1710
- }
1711
-
1712
- this.send(ws, { type: 'gitUnstaged', tabId, data: { paths } });
1713
- } catch (error: any) {
1714
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1715
- }
1716
- }
1717
-
1718
- /**
1719
- * Handle git commit request (with user-provided message)
1720
- */
1721
- private async handleGitCommit(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
1722
- const message = msg.data?.message as string | undefined;
1723
- if (!message) {
1724
- this.send(ws, { type: 'gitError', tabId, data: { error: 'Commit message is required' } });
1725
- return;
1726
- }
1727
-
1728
- try {
1729
- // Commit all staged changes directly - no pre-check to avoid race conditions
1730
- const result = await this.executeGitCommand(['commit', '-m', message], workingDir);
1731
- if (result.exitCode !== 0) {
1732
- let errorMsg = result.stderr || result.stdout || 'Failed to commit';
1733
- if (errorMsg.includes('nothing to commit') || errorMsg.includes('no changes added')) {
1734
- errorMsg = 'No changes staged for commit. Use "Stage" to add files before committing.';
1735
- // Refresh status to sync UI
1736
- this.handleGitStatus(ws, tabId, workingDir);
1737
- }
1738
- this.send(ws, { type: 'gitError', tabId, data: { error: errorMsg } });
1739
- return;
1740
- }
1741
-
1742
- // Get the new commit hash
1743
- const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
1744
- const hash = hashResult.stdout.trim();
1745
-
1746
- this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message } });
1747
- } catch (error: any) {
1748
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1749
- }
1750
- }
1751
-
1752
- /**
1753
- * Handle git commit with AI-generated message
1754
- * Uses Claude Code to analyze staged changes and generate a commit message
1755
- */
1756
- private async handleGitCommitWithAI(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
1757
- try {
1758
- // First check if there are staged changes
1759
- const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1760
- const { staged } = this.parseGitStatus(statusResult.stdout);
1761
-
1762
- if (staged.length === 0) {
1763
- this.send(ws, { type: 'gitError', tabId, data: { error: 'No staged changes to commit' } });
1764
- return;
1765
- }
1766
-
1767
- // Get the diff of staged changes
1768
- const diffResult = await this.executeGitCommand(['diff', '--cached'], workingDir);
1769
- const diff = diffResult.stdout;
1770
-
1771
- // Get recent commit messages for style reference
1772
- const logResult = await this.executeGitCommand(['log', '--oneline', '-5'], workingDir);
1773
- const recentCommits = logResult.stdout.trim();
1774
-
1775
- // Create temp directory if it doesn't exist
1776
- const tempDir = join(workingDir, '.mstro', 'tmp');
1777
- if (!existsSync(tempDir)) {
1778
- mkdirSync(tempDir, { recursive: true });
1779
- }
1780
-
1781
- // Truncate diff if too long
1782
- let truncatedDiff = diff;
1783
- if (diff.length > 8000) {
1784
- truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
1785
- }
1786
-
1787
- // Build prompt for commit message generation
1788
- const prompt = `You are generating a git commit message for the following staged changes.
1789
-
1790
- RECENT COMMIT MESSAGES (for style reference):
1791
- ${recentCommits || 'No recent commits'}
1792
-
1793
- STAGED FILES:
1794
- ${staged.map(f => `${f.status} ${f.path}`).join('\n')}
1795
-
1796
- DIFF OF STAGED CHANGES:
1797
- ${truncatedDiff}
1798
-
1799
- Generate a commit message following these rules:
1800
- 1. First line: imperative mood, max 72 characters (e.g., "Add user authentication", "Fix memory leak in parser")
1801
- 2. If the changes are complex, add a blank line then bullet points explaining the key changes
1802
- 3. Focus on the "why" not just the "what"
1803
- 4. Match the style of recent commits if possible
1804
- 5. No emojis unless the repo already uses them
1805
-
1806
- Respond with ONLY the commit message, nothing else.`;
1807
-
1808
- // Write prompt to temp file
1809
- const promptFile = join(tempDir, `commit-msg-${Date.now()}.txt`);
1810
- writeFileSync(promptFile, prompt);
1811
-
1812
- const systemPrompt = 'You are a commit message assistant. Respond with only the commit message, no preamble or explanation.';
1813
-
1814
- const args = [
1815
- '--print',
1816
- '--model', 'haiku',
1817
- '--system-prompt', systemPrompt,
1818
- promptFile
1819
- ];
1820
-
1821
- const claude = spawn('claude', args, {
1822
- cwd: workingDir,
1823
- stdio: ['ignore', 'pipe', 'pipe']
1824
- });
1825
-
1826
- let stdout = '';
1827
- let stderr = '';
1828
-
1829
- claude.stdout?.on('data', (data: Buffer) => {
1830
- stdout += data.toString();
1831
- });
1832
-
1833
- claude.stderr?.on('data', (data: Buffer) => {
1834
- stderr += data.toString();
1835
- });
1836
-
1837
- claude.on('close', async (code: number | null) => {
1838
- // Clean up temp file
1839
- try {
1840
- unlinkSync(promptFile);
1841
- } catch {
1842
- // Ignore cleanup errors
1843
- }
1844
-
1845
- if (code !== 0 || !stdout.trim()) {
1846
- console.error('[WebSocketImproviseHandler] Claude commit message error:', stderr || 'No output');
1847
- this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
1848
- return;
1849
- }
1850
-
1851
- // Post-process to extract just the commit message
1852
- // Claude sometimes outputs reasoning before the actual message
1853
- const commitMessage = this.extractCommitMessage(stdout.trim());
1854
- const autoCommit = !!msg.data?.autoCommit;
1855
-
1856
- // Send the generated message for preview (include autoCommit flag so frontend knows if commit is pending)
1857
- this.send(ws, { type: 'gitCommitMessage', tabId, data: { message: commitMessage, autoCommit } });
1858
-
1859
- // If autoCommit is true, proceed with the commit
1860
- if (msg.data?.autoCommit) {
1861
- const commitResult = await this.executeGitCommand(['commit', '-m', commitMessage], workingDir);
1862
- if (commitResult.exitCode !== 0) {
1863
- this.send(ws, { type: 'gitError', tabId, data: { error: commitResult.stderr || commitResult.stdout || 'Failed to commit' } });
1864
- return;
1865
- }
1866
-
1867
- // Get the new commit hash
1868
- const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
1869
- const hash = hashResult.stdout.trim();
1870
-
1871
- this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message: commitMessage } });
1872
- }
1873
- });
1874
-
1875
- claude.on('error', (err: Error) => {
1876
- console.error('[WebSocketImproviseHandler] Failed to spawn Claude for commit:', err);
1877
- this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
1878
- });
1879
-
1880
- // Timeout after 30 seconds
1881
- setTimeout(() => {
1882
- claude.kill();
1883
- }, 30000);
1884
-
1885
- } catch (error: any) {
1886
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1887
- }
1888
- }
1889
-
1890
- /**
1891
- * Extract the actual commit message from Claude's output.
1892
- * Sometimes Claude outputs reasoning before the actual message, so we need to parse it.
1893
- */
1894
- private extractCommitMessage(output: string): string {
1895
- // Look for common patterns where Claude introduces the commit message
1896
- const patterns = [
1897
- /(?:here'?s?\s+(?:the\s+)?commit\s+message:?\s*\n+)([\s\S]+)/i,
1898
- /(?:commit\s+message:?\s*\n+)([\s\S]+)/i,
1899
- /(?:suggested\s+commit\s+message:?\s*\n+)([\s\S]+)/i,
1900
- ];
1901
-
1902
- for (const pattern of patterns) {
1903
- const match = output.match(pattern);
1904
- if (match?.[1]) {
1905
- return match[1].trim();
1906
- }
1907
- }
1908
-
1909
- // Split into paragraphs for analysis
1910
- const paragraphs = output.split(/\n\n+/).filter(p => p.trim());
1911
-
1912
- // If only one paragraph, return it as-is
1913
- if (paragraphs.length <= 1) {
1914
- return output.trim();
1915
- }
1916
-
1917
- const firstParagraph = paragraphs[0].trim();
1918
- const firstLine = firstParagraph.split('\n')[0].trim();
1919
-
1920
- // Check if first paragraph looks like reasoning/self-talk
1921
- // Reasoning typically: starts with certain words, is conversational, explains what will happen
1922
- const reasoningPatterns = [
1923
- /^(Now|Based|Looking|After|Here|Let me|I\s+(can|will|see|notice|'ll|would))/i,
1924
- /^The\s+\w+\s+(file|changes?|commit|diff)/i,
1925
- /\b(I can|I will|I'll|let me|analyzing|looking at)\b/i,
1926
- ];
1927
-
1928
- const looksLikeReasoning = reasoningPatterns.some(p => p.test(firstParagraph));
1929
-
1930
- // Also check if first line is too long or conversational for a commit title
1931
- const firstLineTooLong = firstLine.length > 80;
1932
- const endsWithPeriod = firstLine.endsWith('.');
1933
-
1934
- if (looksLikeReasoning || (firstLineTooLong && endsWithPeriod)) {
1935
- // Skip the first paragraph (reasoning) and return the rest
1936
- const commitMessage = paragraphs.slice(1).join('\n\n').trim();
1937
-
1938
- // Validate the extracted message has a reasonable first line
1939
- const extractedFirstLine = commitMessage.split('\n')[0].trim();
1940
- if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
1941
- return commitMessage;
1942
- }
1943
- }
1944
-
1945
- // Check if the second paragraph looks like a proper commit title
1946
- // (short, starts with capital, imperative mood)
1947
- if (paragraphs.length >= 2) {
1948
- const secondParagraph = paragraphs[1].trim();
1949
- const secondFirstLine = secondParagraph.split('\n')[0].trim();
1950
-
1951
- // Commit titles are typically short and start with imperative verb
1952
- if (secondFirstLine.length <= 72 &&
1953
- /^[A-Z][a-z]/.test(secondFirstLine) &&
1954
- !secondFirstLine.endsWith('.')) {
1955
- // Return from second paragraph onwards
1956
- return paragraphs.slice(1).join('\n\n').trim();
1957
- }
1958
- }
1959
-
1960
- // Fall back to original output if we can't identify a better message
1961
- return output.trim();
1962
- }
1963
-
1964
- /**
1965
- * Handle git push request
1966
- */
1967
- private async handleGitPush(ws: WSContext, tabId: string, workingDir: string): Promise<void> {
1968
- try {
1969
- const result = await this.executeGitCommand(['push'], workingDir);
1970
- if (result.exitCode !== 0) {
1971
- this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
1972
- return;
1973
- }
1974
-
1975
- this.send(ws, { type: 'gitPushed', tabId, data: { output: result.stdout || result.stderr } });
1976
- } catch (error: any) {
1977
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
1978
- }
1979
- }
1980
-
1981
- /**
1982
- * Handle git log request
1983
- */
1984
- private async handleGitLog(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
1985
- const limit = msg.data?.limit ?? 10;
1986
-
1987
- try {
1988
- const result = await this.executeGitCommand([
1989
- 'log',
1990
- `-${limit}`,
1991
- '--format=%H|%h|%s|%an|%aI'
1992
- ], workingDir);
1993
-
1994
- if (result.exitCode !== 0) {
1995
- this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to get log' } });
1996
- return;
1997
- }
1998
-
1999
- const entries: GitLogEntry[] = result.stdout.trim().split('\n').filter(Boolean).map(line => {
2000
- const [hash, shortHash, subject, author, date] = line.split('|');
2001
- return { hash, shortHash, subject, author, date };
2002
- });
2003
-
2004
- this.send(ws, { type: 'gitLog', tabId, data: { entries } });
2005
- } catch (error: any) {
2006
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
2007
- }
2008
- }
2009
-
2010
- /** Directories to skip when scanning for git repos */
2011
- private static readonly SKIP_DIRS = ['node_modules', 'vendor', '.git'];
2012
-
2013
- /** Get the current branch name for a git repository */
2014
- private async getRepoBranch(repoPath: string): Promise<string | undefined> {
2015
- const result = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
2016
- return result.exitCode === 0 ? result.stdout.trim() : undefined;
2017
- }
2018
-
2019
- /** Check if a directory name should be skipped when scanning */
2020
- private shouldSkipDir(name: string): boolean {
2021
- return name.startsWith('.') || WebSocketImproviseHandler.SKIP_DIRS.includes(name);
2022
- }
2023
-
2024
- /** Recursively scan directories for git repositories */
2025
- private async scanForGitRepos(dir: string, depth: number, maxDepth: number, repos: GitRepoInfo[]): Promise<void> {
2026
- if (depth > maxDepth) return;
2027
-
2028
- let entries: string[];
2029
- try {
2030
- entries = readdirSync(dir);
2031
- } catch {
2032
- return;
2033
- }
2034
-
2035
- for (const name of entries) {
2036
- if (this.shouldSkipDir(name)) continue;
2037
-
2038
- const fullPath = join(dir, name);
2039
- const gitPath = join(fullPath, '.git');
2040
-
2041
- if (existsSync(gitPath)) {
2042
- repos.push({ path: fullPath, name, branch: await this.getRepoBranch(fullPath) });
2043
- } else {
2044
- await this.scanForGitRepos(fullPath, depth + 1, maxDepth, repos);
2045
- }
2046
- }
2047
- }
2048
-
2049
- /**
2050
- * Discover git repositories in the working directory and subdirectories
2051
- */
2052
- private async handleGitDiscoverRepos(ws: WSContext, tabId: string, workingDir: string): Promise<void> {
2053
- try {
2054
- const repos: GitRepoInfo[] = [];
2055
- const rootIsGitRepo = existsSync(join(workingDir, '.git'));
2056
-
2057
- if (rootIsGitRepo) {
2058
- repos.push({
2059
- path: workingDir,
2060
- name: workingDir.split('/').pop() || workingDir,
2061
- branch: await this.getRepoBranch(workingDir),
2062
- });
2063
- } else {
2064
- await this.scanForGitRepos(workingDir, 1, 3, repos);
2065
- }
2066
-
2067
- const response: GitReposDiscoveredResponse = {
2068
- repos,
2069
- rootIsGitRepo,
2070
- selectedDirectory: this.gitDirectories.get(tabId) || null,
2071
- };
2072
-
2073
- this.send(ws, { type: 'gitReposDiscovered', tabId, data: response });
2074
- } catch (error: any) {
2075
- this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
2076
- }
2077
- }
2078
-
2079
- /**
2080
- * Set the git directory for operations
2081
- */
2082
- private async handleGitSetDirectory(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
2083
- const directory = msg.data?.directory as string | undefined;
2084
-
2085
- if (!directory) {
2086
- // Clear the selected directory, use working dir
2087
- this.gitDirectories.delete(tabId);
2088
- const response: GitDirectorySetResponse = {
2089
- directory: workingDir,
2090
- isValid: existsSync(join(workingDir, '.git')),
2091
- };
2092
- this.send(ws, { type: 'gitDirectorySet', tabId, data: response });
2093
- // Refresh status with new directory
2094
- this.handleGitStatus(ws, tabId, workingDir);
2095
- return;
2096
- }
2097
-
2098
- // Validate the directory exists and has a .git folder
2099
- const gitPath = join(directory, '.git');
2100
- const isValid = existsSync(gitPath);
2101
-
2102
- if (isValid) {
2103
- this.gitDirectories.set(tabId, directory);
2104
- }
2105
-
2106
- const response: GitDirectorySetResponse = {
2107
- directory,
2108
- isValid,
2109
- };
2110
-
2111
- this.send(ws, { type: 'gitDirectorySet', tabId, data: response });
2112
-
2113
- // Refresh status with new directory
2114
- if (isValid) {
2115
- this.handleGitStatus(ws, tabId, directory);
2116
- this.handleGitLog(ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
2117
- }
2118
- }
2119
-
2120
- // ============================================
2121
- // Terminal handling methods
2122
- // ============================================
2123
-
2124
- /**
2125
- * Initialize a new terminal session or reconnect to existing one
2126
- */
2127
- private handleTerminalInit(
2128
- ws: WSContext,
2129
- terminalId: string,
2130
- workingDir: string,
2131
- requestedShell?: string,
2132
- cols?: number,
2133
- rows?: number
2134
- ): void {
2135
-
2136
- const ptyManager = getPTYManager();
2137
-
2138
- // Check if PTY is available (node-pty requires native compilation)
2139
- if (!ptyManager.isPtyAvailable()) {
2140
- this.send(ws, {
2141
- type: 'terminalError',
2142
- terminalId,
2143
- data: {
2144
- error: 'PTY_NOT_AVAILABLE',
2145
- instructions: ptyManager.getPtyInstallInstructions()
2146
- }
2147
- });
2148
- return;
2149
- }
2150
-
2151
- // Add this WS as a subscriber for this terminal's output
2152
- this.addTerminalSubscriber(terminalId, ws);
2153
-
2154
- // Set up broadcast listeners (idempotent — only creates once per terminal)
2155
- this.setupTerminalBroadcastListeners(terminalId);
2156
-
2157
- try {
2158
- // Create or reconnect to the PTY process
2159
- const { shell, cwd, isReconnect } = ptyManager.create(
2160
- terminalId,
2161
- workingDir,
2162
- cols || 80,
2163
- rows || 24,
2164
- requestedShell
2165
- );
2166
-
2167
- // If reconnecting, send scrollback buffer to THIS client only
2168
- if (isReconnect) {
2169
- const scrollback = ptyManager.getScrollback(terminalId);
2170
- if (scrollback.length > 0) {
2171
- this.send(ws, {
2172
- type: 'terminalScrollback',
2173
- terminalId,
2174
- data: { lines: scrollback }
2175
- });
2176
- }
2177
- } else {
2178
- // New terminal — broadcast to other clients so they can create matching tabs
2179
- this.broadcastToOthers(ws, {
2180
- type: 'terminalCreated',
2181
- data: { terminalId, shell, cwd, persistent: false }
2182
- });
2183
- }
2184
-
2185
- // Send ready message to THIS client
2186
- this.send(ws, {
2187
- type: 'terminalReady',
2188
- terminalId,
2189
- data: { shell, cwd, isReconnect }
2190
- });
2191
- trackEvent(AnalyticsEvents.TERMINAL_SESSION_CREATED, {
2192
- shell,
2193
- is_reconnect: isReconnect,
2194
- });
2195
- } catch (error: any) {
2196
- console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
2197
- this.send(ws, {
2198
- type: 'terminalError',
2199
- terminalId,
2200
- data: { error: error.message || 'Failed to create terminal' }
2201
- });
2202
- this.removeTerminalSubscriber(terminalId, ws);
2203
- }
2204
- }
2205
-
2206
- /**
2207
- * Reconnect to an existing terminal session
2208
- */
2209
- private handleTerminalReconnect(ws: WSContext, terminalId: string): void {
2210
-
2211
- const ptyManager = getPTYManager();
2212
-
2213
- // Check if session exists
2214
- const sessionInfo = ptyManager.getSessionInfo(terminalId);
2215
- if (!sessionInfo) {
2216
- this.send(ws, {
2217
- type: 'terminalError',
2218
- terminalId,
2219
- data: { error: 'Terminal session not found', sessionNotFound: true }
2220
- });
2221
- return;
2222
- }
2223
-
2224
- // Add this WS as a subscriber for this terminal's output
2225
- this.addTerminalSubscriber(terminalId, ws);
2226
-
2227
- // Set up broadcast listeners (idempotent — only creates once per terminal)
2228
- this.setupTerminalBroadcastListeners(terminalId);
2229
-
2230
- // Send scrollback buffer to THIS client only
2231
- const scrollback = ptyManager.getScrollback(terminalId);
2232
- if (scrollback.length > 0) {
2233
- this.send(ws, {
2234
- type: 'terminalScrollback',
2235
- terminalId,
2236
- data: { lines: scrollback }
2237
- });
2238
- }
2239
-
2240
- // Send ready message indicating reconnection
2241
- this.send(ws, {
2242
- type: 'terminalReady',
2243
- terminalId,
2244
- data: {
2245
- shell: sessionInfo.shell,
2246
- cwd: sessionInfo.cwd,
2247
- isReconnect: true
2248
- }
2249
- });
2250
-
2251
- // Force a resize to trigger SIGWINCH, causing the shell to redraw its prompt
2252
- ptyManager.resize(terminalId, sessionInfo.cols, sessionInfo.rows);
2253
- }
2254
-
2255
- /**
2256
- * List all active terminal sessions
2257
- */
2258
- private handleTerminalList(ws: WSContext): void {
2259
- const ptyManager = getPTYManager();
2260
- const terminalIds = ptyManager.getActiveTerminals();
2261
-
2262
- const terminals = terminalIds.map(id => {
2263
- const info = ptyManager.getSessionInfo(id);
2264
- return info ? { id, ...info } : null;
2265
- }).filter(Boolean);
2266
-
2267
- this.send(ws, {
2268
- type: 'terminalList',
2269
- data: { terminals }
2270
- });
2271
- }
2272
-
2273
- /**
2274
- * Handle terminal input
2275
- */
2276
- private handleTerminalInput(
2277
- ws: WSContext,
2278
- terminalId: string,
2279
- input?: string
2280
- ): void {
2281
- if (!input) {
2282
- return;
2283
- }
2284
-
2285
- // Check if this is a persistent terminal first
2286
- const persistentHandler = this.persistentHandlers.get(terminalId);
2287
- if (persistentHandler) {
2288
- persistentHandler.write(input);
2289
- return;
2290
- }
2291
-
2292
- // Otherwise use regular PTY
2293
- const ptyManager = getPTYManager();
2294
- const success = ptyManager.write(terminalId, input);
2295
-
2296
- if (!success) {
2297
- this.send(ws, {
2298
- type: 'terminalError',
2299
- terminalId,
2300
- data: { error: 'Terminal not found or write failed' }
2301
- });
2302
- }
2303
- }
2304
-
2305
- /**
2306
- * Handle terminal resize
2307
- */
2308
- private handleTerminalResize(
2309
- _ws: WSContext,
2310
- terminalId: string,
2311
- cols?: number,
2312
- rows?: number
2313
- ): void {
2314
- if (!cols || !rows) {
2315
- return;
2316
- }
2317
-
2318
- // Check if this is a persistent terminal first
2319
- const persistentHandler = this.persistentHandlers.get(terminalId);
2320
- if (persistentHandler) {
2321
- persistentHandler.resize(cols, rows);
2322
- return;
2323
- }
2324
-
2325
- // Otherwise use regular PTY
2326
- const ptyManager = getPTYManager();
2327
- ptyManager.resize(terminalId, cols, rows);
2328
- }
2329
-
2330
- /**
2331
- * Handle terminal close
2332
- */
2333
- private handleTerminalClose(ws: WSContext, terminalId: string): void {
2334
- trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
2335
-
2336
- // Check if this is a persistent terminal first
2337
- const persistentHandler = this.persistentHandlers.get(terminalId);
2338
- if (persistentHandler) {
2339
- persistentHandler.detach();
2340
- this.persistentHandlers.delete(terminalId);
2341
- // For persistent terminals, close actually kills the tmux session
2342
- const ptyManager = getPTYManager();
2343
- ptyManager.closePersistent(terminalId);
2344
- } else {
2345
- // Clean up event listeners
2346
- const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
2347
- if (listenerCleanup) {
2348
- listenerCleanup();
2349
- this.terminalListenerCleanups.delete(terminalId);
2350
- }
2351
-
2352
- // Close regular PTY
2353
- const ptyManager = getPTYManager();
2354
- ptyManager.close(terminalId);
2355
- }
2356
-
2357
- // Clean up subscribers
2358
- this.terminalSubscribers.delete(terminalId);
2359
-
2360
- // Broadcast to other clients
2361
- this.broadcastToOthers(ws, {
2362
- type: 'terminalClosed',
2363
- data: { terminalId }
2364
- });
2365
- }
2366
-
2367
- // Persistent terminal handlers for tmux-backed sessions
2368
- private persistentHandlers: Map<string, { write: (data: string) => void; resize: (cols: number, rows: number) => void; detach: () => void }> = new Map();
2369
-
2370
- // Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
2371
- private terminalListenerCleanups: Map<string, () => void> = new Map();
2372
-
2373
- // Track which WS connections are subscribed to each terminal's output
2374
- private terminalSubscribers: Map<string, Set<WSContext>> = new Map();
2375
-
2376
- /**
2377
- * Add a WS connection as a subscriber for terminal output.
2378
- */
2379
- private addTerminalSubscriber(terminalId: string, ws: WSContext): void {
2380
- let subs = this.terminalSubscribers.get(terminalId);
2381
- if (!subs) {
2382
- subs = new Set();
2383
- this.terminalSubscribers.set(terminalId, subs);
2384
- }
2385
- subs.add(ws);
2386
- }
2387
-
2388
- /**
2389
- * Remove a WS subscriber from a terminal and clean up if no subscribers remain.
2390
- */
2391
- private removeTerminalSubscriber(terminalId: string, ws: WSContext): void {
2392
- const subs = this.terminalSubscribers.get(terminalId);
2393
- if (!subs) return;
2394
- subs.delete(ws);
2395
- if (subs.size > 0) return;
2396
- this.terminalSubscribers.delete(terminalId);
2397
- const cleanup = this.terminalListenerCleanups.get(terminalId);
2398
- if (cleanup) {
2399
- cleanup();
2400
- this.terminalListenerCleanups.delete(terminalId);
2401
- }
2402
- }
2403
-
2404
- /**
2405
- * Attach persistent (tmux) terminal handlers for output/exit broadcasting.
2406
- */
2407
- private attachPersistentHandlers(terminalId: string, ptyManager: ReturnType<typeof getPTYManager>): void {
2408
- const handlers = ptyManager.attachPersistent(
2409
- terminalId,
2410
- (output: string) => {
2411
- const subs = this.terminalSubscribers.get(terminalId);
2412
- if (subs) {
2413
- for (const sub of subs) {
2414
- this.send(sub, { type: 'terminalOutput', terminalId, data: { output } });
2415
- }
2416
- }
2417
- },
2418
- (exitCode: number) => {
2419
- const subs = this.terminalSubscribers.get(terminalId);
2420
- if (subs) {
2421
- for (const sub of subs) {
2422
- this.send(sub, { type: 'terminalExit', terminalId, data: { exitCode } });
2423
- }
2424
- }
2425
- this.persistentHandlers.delete(terminalId);
2426
- this.terminalSubscribers.delete(terminalId);
2427
- }
2428
- );
2429
- if (handlers) {
2430
- this.persistentHandlers.set(terminalId, handlers);
2431
- }
2432
- }
2433
-
2434
- /**
2435
- * Set up PTY event listeners that broadcast to all subscribers.
2436
- * Only creates listeners once per terminal (idempotent).
2437
- */
2438
- private setupTerminalBroadcastListeners(terminalId: string): void {
2439
- // Already set up - don't duplicate
2440
- if (this.terminalListenerCleanups.has(terminalId)) return;
2441
-
2442
- const ptyManager = getPTYManager();
2443
-
2444
- const onOutput = (tid: string, data: string) => {
2445
- if (tid === terminalId) {
2446
- const subs = this.terminalSubscribers.get(terminalId);
2447
- if (subs) {
2448
- for (const ws of subs) {
2449
- this.send(ws, { type: 'terminalOutput', terminalId, data: { output: data } });
2450
- }
2451
- }
2452
- }
2453
- };
2454
-
2455
- const onExit = (tid: string, exitCode: number) => {
2456
- if (tid === terminalId) {
2457
- const subs = this.terminalSubscribers.get(terminalId);
2458
- if (subs) {
2459
- for (const ws of subs) {
2460
- this.send(ws, { type: 'terminalExit', terminalId, data: { exitCode } });
2461
- }
2462
- }
2463
- // Clean up
2464
- ptyManager.off('output', onOutput);
2465
- ptyManager.off('exit', onExit);
2466
- ptyManager.off('error', onError);
2467
- this.terminalListenerCleanups.delete(terminalId);
2468
- this.terminalSubscribers.delete(terminalId);
2469
- }
2470
- };
2471
-
2472
- const onError = (tid: string, error: string) => {
2473
- if (tid === terminalId) {
2474
- const subs = this.terminalSubscribers.get(terminalId);
2475
- if (subs) {
2476
- for (const ws of subs) {
2477
- this.send(ws, { type: 'terminalError', terminalId, data: { error } });
2478
- }
2479
- }
2480
- }
2481
- };
2482
-
2483
- ptyManager.on('output', onOutput);
2484
- ptyManager.on('exit', onExit);
2485
- ptyManager.on('error', onError);
2486
-
2487
- this.terminalListenerCleanups.set(terminalId, () => {
2488
- ptyManager.off('output', onOutput);
2489
- ptyManager.off('exit', onExit);
2490
- ptyManager.off('error', onError);
2491
- });
2492
- }
2493
-
2494
- /**
2495
- * Initialize a persistent (tmux-backed) terminal session
2496
- * These sessions survive server restarts.
2497
- * Uses subscriber pattern for multi-client output broadcasting.
2498
- */
2499
- private handleTerminalInitPersistent(
2500
- ws: WSContext,
2501
- terminalId: string,
2502
- workingDir: string,
2503
- requestedShell?: string,
2504
- cols?: number,
2505
- rows?: number
2506
- ): void {
2507
-
2508
- const ptyManager = getPTYManager();
2509
-
2510
- // Check if tmux is available
2511
- if (!ptyManager.isTmuxAvailable()) {
2512
- this.send(ws, {
2513
- type: 'terminalError',
2514
- terminalId,
2515
- data: { error: 'Persistent terminals require tmux, which is not installed' }
2516
- });
2517
- return;
2518
- }
2519
-
2520
- // Add this WS as a subscriber for this terminal's output
2521
- this.addTerminalSubscriber(terminalId, ws);
2522
-
2523
- try {
2524
- // Create or reconnect to the persistent session
2525
- const { shell, cwd, isReconnect } = ptyManager.createPersistent(
2526
- terminalId,
2527
- workingDir,
2528
- cols || 80,
2529
- rows || 24,
2530
- requestedShell
2531
- );
2532
-
2533
- // Only attach if we don't already have handlers (first subscriber)
2534
- if (!this.persistentHandlers.has(terminalId)) {
2535
- this.attachPersistentHandlers(terminalId, ptyManager);
2536
- }
2537
-
2538
- // If reconnecting, send scrollback buffer to THIS client only
2539
- if (isReconnect) {
2540
- const scrollback = ptyManager.getPersistentScrollback(terminalId);
2541
- if (scrollback.length > 0) {
2542
- this.send(ws, {
2543
- type: 'terminalScrollback',
2544
- terminalId,
2545
- data: { lines: scrollback }
2546
- });
2547
- }
2548
- } else {
2549
- // New terminal — broadcast to other clients so they can create matching tabs
2550
- this.broadcastToOthers(ws, {
2551
- type: 'terminalCreated',
2552
- data: { terminalId, shell, cwd, persistent: true }
2553
- });
2554
- }
2555
-
2556
- // Send ready message to THIS client
2557
- this.send(ws, {
2558
- type: 'terminalReady',
2559
- terminalId,
2560
- data: { shell, cwd, isReconnect, persistent: true }
2561
- });
2562
- } catch (error: any) {
2563
- console.error(`[WebSocketImproviseHandler] Failed to create persistent terminal:`, error);
2564
- this.send(ws, {
2565
- type: 'terminalError',
2566
- terminalId,
2567
- data: { error: error.message || 'Failed to create persistent terminal' }
2568
- });
2569
- this.removeTerminalSubscriber(terminalId, ws);
2570
- }
2571
- }
2572
-
2573
- /**
2574
- * List all persistent terminal sessions (including those from previous server runs)
2575
- */
2576
- private handleTerminalListPersistent(ws: WSContext): void {
2577
- const ptyManager = getPTYManager();
2578
- const sessions = ptyManager.getPersistentSessions();
2579
-
2580
- this.send(ws, {
2581
- type: 'terminalListPersistent',
2582
- data: {
2583
- available: ptyManager.isTmuxAvailable(),
2584
- terminals: sessions.map(s => ({
2585
- id: s.terminalId,
2586
- shell: s.shell,
2587
- cwd: s.cwd,
2588
- createdAt: s.createdAt,
2589
- lastAttachedAt: s.lastAttachedAt,
2590
- }))
2591
- }
2592
- });
2593
- }
2594
263
  }