mstro-app 0.2.0 → 0.3.1

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