mstro-app 0.3.5 → 0.3.6
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/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +5 -10
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +4 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +39 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +19 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +48 -1
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-upload-handler.d.ts +44 -0
- package/dist/server/services/websocket/file-upload-handler.d.ts.map +1 -0
- package/dist/server/services/websocket/file-upload-handler.js +185 -0
- package/dist/server/services/websocket/file-upload-handler.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +1 -1
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +3 -3
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +40 -2
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +3 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +4 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +31 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +69 -20
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +6 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +16 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +33 -24
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.d.ts +4 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +35 -4
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker.ts +5 -11
- package/server/cli/improvisation-session-manager.ts +42 -1
- package/server/services/terminal/pty-manager.ts +57 -2
- package/server/services/websocket/file-upload-handler.ts +259 -0
- package/server/services/websocket/git-handlers.ts +3 -3
- package/server/services/websocket/git-worktree-handlers.ts +47 -3
- package/server/services/websocket/handler-context.ts +3 -0
- package/server/services/websocket/handler.ts +33 -0
- package/server/services/websocket/session-handlers.ts +79 -20
- package/server/services/websocket/session-registry.ts +18 -0
- package/server/services/websocket/tab-handlers.ts +44 -23
- package/server/services/websocket/terminal-handlers.ts +40 -4
- package/server/services/websocket/types.ts +14 -2
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Chunked File Upload Handler
|
|
6
|
+
*
|
|
7
|
+
* Receives files in chunks over WebSocket from remote web clients,
|
|
8
|
+
* writes them to .mstro/tmp/attachments/{tabId}/, and sends progress acks back.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { WriteStream } from 'node:fs';
|
|
12
|
+
import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { WebSocketResponse, WSContext } from './types.js';
|
|
15
|
+
|
|
16
|
+
interface UploadState {
|
|
17
|
+
uploadId: string;
|
|
18
|
+
fileName: string;
|
|
19
|
+
fileSize: number;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
isImage: boolean;
|
|
22
|
+
totalChunks: number;
|
|
23
|
+
receivedChunks: number;
|
|
24
|
+
filePath: string;
|
|
25
|
+
stream: WriteStream;
|
|
26
|
+
lastActivity: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Completed upload that's ready to be referenced in an execute message */
|
|
30
|
+
export interface CompletedUpload {
|
|
31
|
+
uploadId: string;
|
|
32
|
+
fileName: string;
|
|
33
|
+
filePath: string;
|
|
34
|
+
isImage: boolean;
|
|
35
|
+
mimeType: string;
|
|
36
|
+
fileSize: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const UPLOAD_TIMEOUT_MS = 120_000; // 2 minutes idle timeout
|
|
40
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
41
|
+
|
|
42
|
+
export class FileUploadHandler {
|
|
43
|
+
private activeUploads = new Map<string, UploadState>();
|
|
44
|
+
private completedUploads = new Map<string, CompletedUpload[]>(); // tabId -> completed uploads
|
|
45
|
+
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
46
|
+
|
|
47
|
+
constructor(private workingDir: string) {
|
|
48
|
+
// Periodically clean up stale uploads
|
|
49
|
+
this.cleanupInterval = setInterval(() => this.cleanupStaleUploads(), 30_000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get completed uploads for a tab and clear them */
|
|
53
|
+
getAndClearCompletedUploads(tabId: string): CompletedUpload[] {
|
|
54
|
+
const uploads = this.completedUploads.get(tabId) || [];
|
|
55
|
+
this.completedUploads.delete(tabId);
|
|
56
|
+
return uploads;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get completed uploads for a tab without clearing */
|
|
60
|
+
getCompletedUploads(tabId: string): CompletedUpload[] {
|
|
61
|
+
return this.completedUploads.get(tabId) || [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleUploadStart(
|
|
65
|
+
ws: WSContext,
|
|
66
|
+
send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
67
|
+
tabId: string,
|
|
68
|
+
data: { uploadId: string; fileName: string; fileSize: number; mimeType: string; isImage: boolean; totalChunks: number }
|
|
69
|
+
): void {
|
|
70
|
+
const { uploadId, fileName, fileSize, mimeType, isImage, totalChunks } = data;
|
|
71
|
+
|
|
72
|
+
// Validate file size
|
|
73
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
74
|
+
send(ws, {
|
|
75
|
+
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
76
|
+
tabId,
|
|
77
|
+
data: { uploadId, error: `File too large: ${(fileSize / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` }
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Create attachment directory
|
|
83
|
+
const attachDir = join(this.workingDir, '.mstro', 'tmp', 'attachments', tabId);
|
|
84
|
+
if (!existsSync(attachDir)) {
|
|
85
|
+
mkdirSync(attachDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Handle duplicate file names
|
|
89
|
+
let targetFileName = fileName;
|
|
90
|
+
let counter = 1;
|
|
91
|
+
while (existsSync(join(attachDir, targetFileName))) {
|
|
92
|
+
const ext = fileName.lastIndexOf('.') !== -1 ? fileName.slice(fileName.lastIndexOf('.')) : '';
|
|
93
|
+
const base = fileName.lastIndexOf('.') !== -1 ? fileName.slice(0, fileName.lastIndexOf('.')) : fileName;
|
|
94
|
+
targetFileName = `${base}-${counter}${ext}`;
|
|
95
|
+
counter++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const filePath = join(attachDir, targetFileName);
|
|
99
|
+
const stream = createWriteStream(filePath);
|
|
100
|
+
|
|
101
|
+
const uploadState: UploadState = {
|
|
102
|
+
uploadId,
|
|
103
|
+
fileName: targetFileName,
|
|
104
|
+
fileSize,
|
|
105
|
+
mimeType,
|
|
106
|
+
isImage,
|
|
107
|
+
totalChunks,
|
|
108
|
+
receivedChunks: 0,
|
|
109
|
+
filePath,
|
|
110
|
+
stream,
|
|
111
|
+
lastActivity: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.activeUploads.set(uploadId, uploadState);
|
|
115
|
+
|
|
116
|
+
// Send ack for start
|
|
117
|
+
send(ws, {
|
|
118
|
+
type: 'fileUploadAck' as WebSocketResponse['type'],
|
|
119
|
+
tabId,
|
|
120
|
+
data: { uploadId, chunkIndex: -1, status: 'ok' }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
handleUploadChunk(
|
|
125
|
+
ws: WSContext,
|
|
126
|
+
send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
127
|
+
tabId: string,
|
|
128
|
+
data: { uploadId: string; chunkIndex: number; content: string }
|
|
129
|
+
): void {
|
|
130
|
+
const { uploadId, chunkIndex, content } = data;
|
|
131
|
+
const upload = this.activeUploads.get(uploadId);
|
|
132
|
+
|
|
133
|
+
if (!upload) {
|
|
134
|
+
send(ws, {
|
|
135
|
+
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
136
|
+
tabId,
|
|
137
|
+
data: { uploadId, error: 'Upload not found or expired' }
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const buffer = Buffer.from(content, 'base64');
|
|
144
|
+
upload.stream.write(buffer);
|
|
145
|
+
upload.receivedChunks++;
|
|
146
|
+
upload.lastActivity = Date.now();
|
|
147
|
+
|
|
148
|
+
send(ws, {
|
|
149
|
+
type: 'fileUploadAck' as WebSocketResponse['type'],
|
|
150
|
+
tabId,
|
|
151
|
+
data: { uploadId, chunkIndex, status: 'ok' }
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
155
|
+
send(ws, {
|
|
156
|
+
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
157
|
+
tabId,
|
|
158
|
+
data: { uploadId, error: `Chunk write failed: ${errorMsg}` }
|
|
159
|
+
});
|
|
160
|
+
this.cancelUpload(uploadId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
handleUploadComplete(
|
|
165
|
+
ws: WSContext,
|
|
166
|
+
send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
167
|
+
tabId: string,
|
|
168
|
+
data: { uploadId: string }
|
|
169
|
+
): void {
|
|
170
|
+
const { uploadId } = data;
|
|
171
|
+
const upload = this.activeUploads.get(uploadId);
|
|
172
|
+
|
|
173
|
+
if (!upload) {
|
|
174
|
+
send(ws, {
|
|
175
|
+
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
176
|
+
tabId,
|
|
177
|
+
data: { uploadId, error: 'Upload not found or expired' }
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
upload.stream.end(() => {
|
|
183
|
+
// Verify file was written
|
|
184
|
+
try {
|
|
185
|
+
const stat = statSync(upload.filePath);
|
|
186
|
+
const completed: CompletedUpload = {
|
|
187
|
+
uploadId,
|
|
188
|
+
fileName: upload.fileName,
|
|
189
|
+
filePath: upload.filePath,
|
|
190
|
+
isImage: upload.isImage,
|
|
191
|
+
mimeType: upload.mimeType,
|
|
192
|
+
fileSize: stat.size,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Store completed upload for this tab
|
|
196
|
+
const tabUploads = this.completedUploads.get(tabId) || [];
|
|
197
|
+
tabUploads.push(completed);
|
|
198
|
+
this.completedUploads.set(tabId, tabUploads);
|
|
199
|
+
|
|
200
|
+
this.activeUploads.delete(uploadId);
|
|
201
|
+
|
|
202
|
+
send(ws, {
|
|
203
|
+
type: 'fileUploadReady' as WebSocketResponse['type'],
|
|
204
|
+
tabId,
|
|
205
|
+
data: { uploadId, filePath: upload.filePath, fileName: upload.fileName }
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
209
|
+
send(ws, {
|
|
210
|
+
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
211
|
+
tabId,
|
|
212
|
+
data: { uploadId, error: `File verification failed: ${errorMsg}` }
|
|
213
|
+
});
|
|
214
|
+
this.activeUploads.delete(uploadId);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
handleUploadCancel(
|
|
220
|
+
_ws: WSContext,
|
|
221
|
+
_send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
222
|
+
_tabId: string,
|
|
223
|
+
data: { uploadId: string }
|
|
224
|
+
): void {
|
|
225
|
+
this.cancelUpload(data.uploadId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private cancelUpload(uploadId: string): void {
|
|
229
|
+
const upload = this.activeUploads.get(uploadId);
|
|
230
|
+
if (!upload) return;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
upload.stream.destroy();
|
|
234
|
+
if (existsSync(upload.filePath)) {
|
|
235
|
+
rmSync(upload.filePath, { force: true });
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Ignore cleanup errors
|
|
239
|
+
}
|
|
240
|
+
this.activeUploads.delete(uploadId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private cleanupStaleUploads(): void {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
for (const [uploadId, upload] of this.activeUploads) {
|
|
246
|
+
if (now - upload.lastActivity > UPLOAD_TIMEOUT_MS) {
|
|
247
|
+
console.warn(`[FileUploadHandler] Upload ${uploadId} timed out, cleaning up`);
|
|
248
|
+
this.cancelUpload(uploadId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
destroy(): void {
|
|
254
|
+
clearInterval(this.cleanupInterval);
|
|
255
|
+
for (const uploadId of this.activeUploads.keys()) {
|
|
256
|
+
this.cancelUpload(uploadId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -188,13 +188,13 @@ const GIT_PR_TYPES = new Set([
|
|
|
188
188
|
|
|
189
189
|
// Worktree/merge message types that route to git-worktree-handlers
|
|
190
190
|
const GIT_WORKTREE_TYPES = new Set([
|
|
191
|
-
'gitWorktreeList', 'gitWorktreeCreate', 'gitWorktreeRemove',
|
|
191
|
+
'gitWorktreeList', 'gitWorktreeCreate', 'gitWorktreeCreateAndAssign', 'gitWorktreeRemove',
|
|
192
192
|
'tabWorktreeSwitch', 'gitWorktreePush', 'gitWorktreeCreatePR',
|
|
193
193
|
'gitMergePreview', 'gitWorktreeMerge', 'gitMergeAbort', 'gitMergeComplete',
|
|
194
194
|
]);
|
|
195
195
|
|
|
196
196
|
/** Route git messages to appropriate sub-handler */
|
|
197
|
-
export function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
197
|
+
export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
198
198
|
const gitDir = ctx.gitDirectories.get(tabId) || workingDir;
|
|
199
199
|
|
|
200
200
|
if (GIT_PR_TYPES.has(msg.type)) {
|
|
@@ -202,7 +202,7 @@ export function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg: WebSoc
|
|
|
202
202
|
return;
|
|
203
203
|
}
|
|
204
204
|
if (GIT_WORKTREE_TYPES.has(msg.type)) {
|
|
205
|
-
handleGitWorktreeMessage(ctx, ws, msg, tabId, gitDir, workingDir);
|
|
205
|
+
await handleGitWorktreeMessage(ctx, ws, msg, tabId, gitDir, workingDir);
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -6,10 +6,11 @@ import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handl
|
|
|
6
6
|
import type { HandlerContext } from './handler-context.js';
|
|
7
7
|
import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
|
|
8
8
|
|
|
9
|
-
export function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): void {
|
|
10
|
-
const handlers: Record<string, () => void
|
|
9
|
+
export async function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
|
|
10
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
11
11
|
gitWorktreeList: () => handleGitWorktreeList(ctx, ws, tabId, gitDir),
|
|
12
12
|
gitWorktreeCreate: () => handleGitWorktreeCreate(ctx, ws, msg, tabId, gitDir),
|
|
13
|
+
gitWorktreeCreateAndAssign: () => handleGitWorktreeCreateAndAssign(ctx, ws, msg, tabId, gitDir, workingDir),
|
|
13
14
|
gitWorktreeRemove: () => handleGitWorktreeRemove(ctx, ws, msg, tabId, gitDir),
|
|
14
15
|
tabWorktreeSwitch: () => handleTabWorktreeSwitch(ctx, ws, msg, tabId, workingDir),
|
|
15
16
|
gitWorktreePush: () => handleGitWorktreePush(ctx, ws, msg, tabId, gitDir),
|
|
@@ -19,7 +20,7 @@ export function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg
|
|
|
19
20
|
gitMergeAbort: () => handleGitMergeAbort(ctx, ws, tabId, gitDir),
|
|
20
21
|
gitMergeComplete: () => handleGitMergeComplete(ctx, ws, msg, tabId, gitDir),
|
|
21
22
|
};
|
|
22
|
-
handlers[msg.type]?.();
|
|
23
|
+
await handlers[msg.type]?.();
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function applyWorktreePorcelainLine(line: string, worktrees: WorktreeInfo[], current: Partial<WorktreeInfo>): Partial<WorktreeInfo> {
|
|
@@ -90,6 +91,44 @@ async function handleGitWorktreeCreate(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
const { branchName, baseBranch, path: worktreePath } = msg.data || {};
|
|
97
|
+
if (!branchName) {
|
|
98
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const repoName = gitDir.split('/').pop() || 'repo';
|
|
103
|
+
const wtPath = worktreePath || join(dirname(gitDir), `${repoName}-worktrees`, branchName);
|
|
104
|
+
|
|
105
|
+
const args = ['worktree', 'add', wtPath, '-b', branchName, ...(baseBranch ? [baseBranch] : [])];
|
|
106
|
+
const result = await executeGitCommand(args, gitDir);
|
|
107
|
+
if (result.exitCode !== 0) {
|
|
108
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to create worktree' } });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const headResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], wtPath);
|
|
113
|
+
|
|
114
|
+
// Assign to tab
|
|
115
|
+
ctx.gitDirectories.set(tabId, wtPath);
|
|
116
|
+
ctx.gitBranches.set(tabId, branchName);
|
|
117
|
+
const registry = ctx.getRegistry(workingDir);
|
|
118
|
+
registry.updateTabWorktree(tabId, wtPath, branchName);
|
|
119
|
+
|
|
120
|
+
ctx.send(ws, {
|
|
121
|
+
type: 'gitWorktreeCreatedAndAssigned',
|
|
122
|
+
tabId,
|
|
123
|
+
data: { tabId, path: wtPath, branch: branchName, head: headResult.stdout.trim() },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
handleGitStatus(ctx, ws, tabId, wtPath);
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
93
132
|
async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
94
133
|
try {
|
|
95
134
|
const { path: wtPath, force, deleteBranch } = msg.data || {};
|
|
@@ -127,8 +166,11 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
127
166
|
try {
|
|
128
167
|
const { tabId: targetTabId, worktreePath } = msg.data || {};
|
|
129
168
|
const resolvedTabId = targetTabId || tabId;
|
|
169
|
+
const registry = ctx.getRegistry(workingDir);
|
|
130
170
|
if (!worktreePath) {
|
|
131
171
|
ctx.gitDirectories.delete(resolvedTabId);
|
|
172
|
+
ctx.gitBranches.delete(resolvedTabId);
|
|
173
|
+
registry.updateTabWorktree(resolvedTabId, null, null);
|
|
132
174
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: workingDir, branch: '' } });
|
|
133
175
|
handleGitStatus(ctx, ws, resolvedTabId, workingDir);
|
|
134
176
|
return;
|
|
@@ -138,6 +180,8 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
138
180
|
|
|
139
181
|
const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
140
182
|
const branch = branchResult.stdout.trim();
|
|
183
|
+
ctx.gitBranches.set(resolvedTabId, branch);
|
|
184
|
+
registry.updateTabWorktree(resolvedTabId, worktreePath, branch);
|
|
141
185
|
|
|
142
186
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
|
|
143
187
|
handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ChildProcess } from 'node:child_process';
|
|
5
5
|
import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
6
6
|
import type { AutocompleteService } from './autocomplete.js';
|
|
7
|
+
import type { FileUploadHandler } from './file-upload-handler.js';
|
|
7
8
|
import type { SessionRegistry } from './session-registry.js';
|
|
8
9
|
import type { WebSocketResponse, WSContext } from './types.js';
|
|
9
10
|
|
|
@@ -25,11 +26,13 @@ export interface HandlerContext {
|
|
|
25
26
|
connections: Map<WSContext, Map<string, string>>;
|
|
26
27
|
allConnections: Set<WSContext>;
|
|
27
28
|
gitDirectories: Map<string, string>;
|
|
29
|
+
gitBranches: Map<string, string>;
|
|
28
30
|
activeSearches: Map<string, ChildProcess>;
|
|
29
31
|
terminalSubscribers: Map<string, Set<WSContext>>;
|
|
30
32
|
terminalListenerCleanups: Map<string, () => void>;
|
|
31
33
|
autocompleteService: AutocompleteService;
|
|
32
34
|
usageReporter: UsageReporter | null;
|
|
35
|
+
fileUploadHandler: FileUploadHandler | null;
|
|
33
36
|
|
|
34
37
|
// Registry access
|
|
35
38
|
getRegistry(workingDir: string): SessionRegistry;
|
|
@@ -16,6 +16,7 @@ import type { ImprovisationSessionManager } from '../../cli/improvisation-sessio
|
|
|
16
16
|
import { captureException } from '../sentry.js';
|
|
17
17
|
import { AutocompleteService } from './autocomplete.js';
|
|
18
18
|
import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
|
|
19
|
+
import { FileUploadHandler } from './file-upload-handler.js';
|
|
19
20
|
import { handleGitMessage } from './git-handlers.js';
|
|
20
21
|
import type { HandlerContext, UsageReporter } from './handler-context.js';
|
|
21
22
|
import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistoricalSession } from './session-handlers.js';
|
|
@@ -34,11 +35,13 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
34
35
|
private frecencyPath: string;
|
|
35
36
|
usageReporter: UsageReporter | null = null;
|
|
36
37
|
gitDirectories: Map<string, string> = new Map();
|
|
38
|
+
gitBranches: Map<string, string> = new Map();
|
|
37
39
|
private sessionRegistry: SessionRegistry | null = null;
|
|
38
40
|
allConnections: Set<WSContext> = new Set();
|
|
39
41
|
activeSearches: Map<string, ChildProcess> = new Map();
|
|
40
42
|
terminalListenerCleanups: Map<string, () => void> = new Map();
|
|
41
43
|
terminalSubscribers: Map<string, Set<WSContext>> = new Map();
|
|
44
|
+
fileUploadHandler: FileUploadHandler | null = null;
|
|
42
45
|
|
|
43
46
|
constructor() {
|
|
44
47
|
this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
|
|
@@ -192,6 +195,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
192
195
|
case 'gitPushTag':
|
|
193
196
|
case 'gitWorktreeList':
|
|
194
197
|
case 'gitWorktreeCreate':
|
|
198
|
+
case 'gitWorktreeCreateAndAssign':
|
|
195
199
|
case 'gitWorktreeRemove':
|
|
196
200
|
case 'tabWorktreeSwitch':
|
|
197
201
|
case 'gitWorktreePush':
|
|
@@ -221,11 +225,40 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
221
225
|
return handleGetSettings(this, ws);
|
|
222
226
|
case 'updateSettings':
|
|
223
227
|
return handleUpdateSettings(this, ws, msg);
|
|
228
|
+
// File upload messages (chunked remote uploads)
|
|
229
|
+
case 'fileUploadStart':
|
|
230
|
+
case 'fileUploadChunk':
|
|
231
|
+
case 'fileUploadComplete':
|
|
232
|
+
case 'fileUploadCancel':
|
|
233
|
+
return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
|
|
224
234
|
default:
|
|
225
235
|
throw new Error(`Unknown message type: ${msg.type}`);
|
|
226
236
|
}
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
private handleFileUploadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
240
|
+
if (!this.fileUploadHandler) {
|
|
241
|
+
this.fileUploadHandler = new FileUploadHandler(workingDir);
|
|
242
|
+
}
|
|
243
|
+
const handler = this.fileUploadHandler;
|
|
244
|
+
const send = this.send.bind(this);
|
|
245
|
+
|
|
246
|
+
switch (msg.type) {
|
|
247
|
+
case 'fileUploadStart':
|
|
248
|
+
handler.handleUploadStart(ws, send, tabId, msg.data);
|
|
249
|
+
break;
|
|
250
|
+
case 'fileUploadChunk':
|
|
251
|
+
handler.handleUploadChunk(ws, send, tabId, msg.data);
|
|
252
|
+
break;
|
|
253
|
+
case 'fileUploadComplete':
|
|
254
|
+
handler.handleUploadComplete(ws, send, tabId, msg.data);
|
|
255
|
+
break;
|
|
256
|
+
case 'fileUploadCancel':
|
|
257
|
+
handler.handleUploadCancel(ws, send, tabId, msg.data);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
229
262
|
handleClose(ws: WSContext): void {
|
|
230
263
|
this.connections.delete(ws);
|
|
231
264
|
this.allConnections.delete(ws);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
6
|
+
import { type FileAttachment, ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
7
7
|
import { getModel } from '../settings.js';
|
|
8
8
|
import type { HandlerContext } from './handler-context.js';
|
|
9
9
|
import type { SessionRegistry } from './session-registry.js';
|
|
@@ -128,6 +128,29 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
|
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Merge pre-uploaded files (from chunked upload) with any inline attachments */
|
|
132
|
+
function mergePreUploadedAttachments(ctx: HandlerContext, tabId: string, inlineAttachments?: FileAttachment[]): FileAttachment[] | undefined {
|
|
133
|
+
if (!ctx.fileUploadHandler) return inlineAttachments;
|
|
134
|
+
const preUploaded = ctx.fileUploadHandler.getAndClearCompletedUploads(tabId);
|
|
135
|
+
if (preUploaded.length === 0) return inlineAttachments;
|
|
136
|
+
|
|
137
|
+
const merged: (FileAttachment & { _preUploaded?: boolean })[] = [...(inlineAttachments || [])];
|
|
138
|
+
for (const upload of preUploaded) {
|
|
139
|
+
const alreadyIncluded = merged.some(a => a.fileName === upload.fileName);
|
|
140
|
+
if (!alreadyIncluded) {
|
|
141
|
+
merged.push({
|
|
142
|
+
fileName: upload.fileName,
|
|
143
|
+
filePath: upload.filePath,
|
|
144
|
+
content: '',
|
|
145
|
+
isImage: upload.isImage,
|
|
146
|
+
mimeType: upload.mimeType,
|
|
147
|
+
_preUploaded: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'control' | 'view'): void {
|
|
132
155
|
switch (msg.type) {
|
|
133
156
|
case 'execute': {
|
|
@@ -135,7 +158,8 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
135
158
|
const session = requireSession(ctx, ws, tabId);
|
|
136
159
|
const sandboxed = permission === 'control' || permission === 'view';
|
|
137
160
|
const worktreeDir = ctx.gitDirectories.get(tabId);
|
|
138
|
-
|
|
161
|
+
const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
|
|
162
|
+
session.executePrompt(msg.data.prompt, attachments, { sandboxed, workingDir: worktreeDir });
|
|
139
163
|
break;
|
|
140
164
|
}
|
|
141
165
|
case 'cancel': {
|
|
@@ -206,6 +230,47 @@ export function handleHistoryMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
206
230
|
}
|
|
207
231
|
}
|
|
208
232
|
|
|
233
|
+
function tryResumeFromDisk(
|
|
234
|
+
ctx: HandlerContext,
|
|
235
|
+
ws: WSContext,
|
|
236
|
+
tabId: string,
|
|
237
|
+
workingDir: string,
|
|
238
|
+
registrySessionId: string,
|
|
239
|
+
tabMap: Map<string, string> | undefined,
|
|
240
|
+
registry: SessionRegistry
|
|
241
|
+
): boolean {
|
|
242
|
+
try {
|
|
243
|
+
const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
|
|
244
|
+
setupSessionListeners(ctx, diskSession, ws, tabId);
|
|
245
|
+
const diskSessionId = diskSession.getSessionInfo().sessionId;
|
|
246
|
+
ctx.sessions.set(diskSessionId, diskSession);
|
|
247
|
+
if (tabMap) tabMap.set(tabId, diskSessionId);
|
|
248
|
+
registry.touchTab(tabId);
|
|
249
|
+
|
|
250
|
+
// Restore worktree state from registry
|
|
251
|
+
const regTab = registry.getTab(tabId);
|
|
252
|
+
if (regTab?.worktreePath && !ctx.gitDirectories.has(tabId)) {
|
|
253
|
+
ctx.gitDirectories.set(tabId, regTab.worktreePath);
|
|
254
|
+
if (regTab.worktreeBranch) ctx.gitBranches.set(tabId, regTab.worktreeBranch);
|
|
255
|
+
}
|
|
256
|
+
const worktreePath = ctx.gitDirectories.get(tabId);
|
|
257
|
+
const worktreeBranch = ctx.gitBranches.get(tabId);
|
|
258
|
+
|
|
259
|
+
ctx.send(ws, {
|
|
260
|
+
type: 'tabInitialized',
|
|
261
|
+
tabId,
|
|
262
|
+
data: {
|
|
263
|
+
...diskSession.getSessionInfo(),
|
|
264
|
+
outputHistory: buildOutputHistory(diskSession),
|
|
265
|
+
...(worktreePath ? { worktreePath, worktreeBranch } : {}),
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
209
274
|
export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string, tabName?: string): Promise<void> {
|
|
210
275
|
const tabMap = ctx.connections.get(ws);
|
|
211
276
|
const registry = ctx.getRegistry(workingDir);
|
|
@@ -229,25 +294,8 @@ export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: s
|
|
|
229
294
|
return;
|
|
230
295
|
}
|
|
231
296
|
|
|
232
|
-
|
|
233
|
-
const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
|
|
234
|
-
setupSessionListeners(ctx, diskSession, ws, tabId);
|
|
235
|
-
const diskSessionId = diskSession.getSessionInfo().sessionId;
|
|
236
|
-
ctx.sessions.set(diskSessionId, diskSession);
|
|
237
|
-
if (tabMap) tabMap.set(tabId, diskSessionId);
|
|
238
|
-
registry.touchTab(tabId);
|
|
239
|
-
|
|
240
|
-
ctx.send(ws, {
|
|
241
|
-
type: 'tabInitialized',
|
|
242
|
-
tabId,
|
|
243
|
-
data: {
|
|
244
|
-
...diskSession.getSessionInfo(),
|
|
245
|
-
outputHistory: buildOutputHistory(diskSession),
|
|
246
|
-
}
|
|
247
|
-
});
|
|
297
|
+
if (tryResumeFromDisk(ctx, ws, tabId, workingDir, registrySessionId, tabMap, registry)) {
|
|
248
298
|
return;
|
|
249
|
-
} catch {
|
|
250
|
-
// Disk session not found — fall through to create new
|
|
251
299
|
}
|
|
252
300
|
}
|
|
253
301
|
|
|
@@ -352,12 +400,22 @@ function reattachSession(
|
|
|
352
400
|
if (tabMap) tabMap.set(tabId, sessionId);
|
|
353
401
|
registry.touchTab(tabId);
|
|
354
402
|
|
|
403
|
+
// Restore worktree state from registry if not already in memory
|
|
404
|
+
const regTab = registry.getTab(tabId);
|
|
405
|
+
if (regTab?.worktreePath && !ctx.gitDirectories.has(tabId)) {
|
|
406
|
+
ctx.gitDirectories.set(tabId, regTab.worktreePath);
|
|
407
|
+
if (regTab.worktreeBranch) ctx.gitBranches.set(tabId, regTab.worktreeBranch);
|
|
408
|
+
}
|
|
409
|
+
|
|
355
410
|
const outputHistory = buildOutputHistory(session);
|
|
356
411
|
|
|
357
412
|
const executionEvents = session.isExecuting
|
|
358
413
|
? session.getExecutionEventLog()
|
|
359
414
|
: undefined;
|
|
360
415
|
|
|
416
|
+
const worktreePath = ctx.gitDirectories.get(tabId);
|
|
417
|
+
const worktreeBranch = ctx.gitBranches.get(tabId);
|
|
418
|
+
|
|
361
419
|
ctx.send(ws, {
|
|
362
420
|
type: 'tabInitialized',
|
|
363
421
|
tabId,
|
|
@@ -367,6 +425,7 @@ function reattachSession(
|
|
|
367
425
|
isExecuting: session.isExecuting,
|
|
368
426
|
executionEvents,
|
|
369
427
|
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
428
|
+
...(worktreePath ? { worktreePath, worktreeBranch } : {}),
|
|
370
429
|
}
|
|
371
430
|
});
|
|
372
431
|
}
|
|
@@ -21,6 +21,8 @@ export interface RegisteredTab {
|
|
|
21
21
|
lastActivityAt: string
|
|
22
22
|
order: number
|
|
23
23
|
hasUnviewedCompletion: boolean
|
|
24
|
+
worktreePath?: string
|
|
25
|
+
worktreeBranch?: string
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface RegistryData {
|
|
@@ -167,6 +169,22 @@ export class SessionRegistry {
|
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Update worktree assignment for a tab. Pass null to clear.
|
|
174
|
+
*/
|
|
175
|
+
updateTabWorktree(tabId: string, worktreePath: string | null, worktreeBranch: string | null): void {
|
|
176
|
+
if (this.data.tabs[tabId]) {
|
|
177
|
+
if (worktreePath) {
|
|
178
|
+
this.data.tabs[tabId].worktreePath = worktreePath
|
|
179
|
+
this.data.tabs[tabId].worktreeBranch = worktreeBranch || undefined
|
|
180
|
+
} else {
|
|
181
|
+
delete this.data.tabs[tabId].worktreePath
|
|
182
|
+
delete this.data.tabs[tabId].worktreeBranch
|
|
183
|
+
}
|
|
184
|
+
this.save()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
/**
|
|
171
189
|
* Reorder tabs. Accepts an ordered array of tabIds and reassigns order values.
|
|
172
190
|
*/
|