mstro-app 0.1.58 → 0.3.0

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