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