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.
Files changed (59) hide show
  1. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker.js +5 -10
  3. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  4. package/dist/server/cli/improvisation-session-manager.d.ts +4 -0
  5. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  6. package/dist/server/cli/improvisation-session-manager.js +39 -1
  7. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  8. package/dist/server/services/terminal/pty-manager.d.ts +19 -0
  9. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  10. package/dist/server/services/terminal/pty-manager.js +48 -1
  11. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  12. package/dist/server/services/websocket/file-upload-handler.d.ts +44 -0
  13. package/dist/server/services/websocket/file-upload-handler.d.ts.map +1 -0
  14. package/dist/server/services/websocket/file-upload-handler.js +185 -0
  15. package/dist/server/services/websocket/file-upload-handler.js.map +1 -0
  16. package/dist/server/services/websocket/git-handlers.d.ts +1 -1
  17. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  18. package/dist/server/services/websocket/git-handlers.js +3 -3
  19. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  20. package/dist/server/services/websocket/git-worktree-handlers.d.ts +1 -1
  21. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  22. package/dist/server/services/websocket/git-worktree-handlers.js +40 -2
  23. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  24. package/dist/server/services/websocket/handler-context.d.ts +3 -0
  25. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  26. package/dist/server/services/websocket/handler.d.ts +4 -0
  27. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  28. package/dist/server/services/websocket/handler.js +31 -0
  29. package/dist/server/services/websocket/handler.js.map +1 -1
  30. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  31. package/dist/server/services/websocket/session-handlers.js +69 -20
  32. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  33. package/dist/server/services/websocket/session-registry.d.ts +6 -0
  34. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  35. package/dist/server/services/websocket/session-registry.js +16 -0
  36. package/dist/server/services/websocket/session-registry.js.map +1 -1
  37. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  38. package/dist/server/services/websocket/tab-handlers.js +33 -24
  39. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  40. package/dist/server/services/websocket/terminal-handlers.d.ts +4 -0
  41. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
  42. package/dist/server/services/websocket/terminal-handlers.js +35 -4
  43. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  44. package/dist/server/services/websocket/types.d.ts +2 -2
  45. package/dist/server/services/websocket/types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/server/cli/headless/claude-invoker.ts +5 -11
  48. package/server/cli/improvisation-session-manager.ts +42 -1
  49. package/server/services/terminal/pty-manager.ts +57 -2
  50. package/server/services/websocket/file-upload-handler.ts +259 -0
  51. package/server/services/websocket/git-handlers.ts +3 -3
  52. package/server/services/websocket/git-worktree-handlers.ts +47 -3
  53. package/server/services/websocket/handler-context.ts +3 -0
  54. package/server/services/websocket/handler.ts +33 -0
  55. package/server/services/websocket/session-handlers.ts +79 -20
  56. package/server/services/websocket/session-registry.ts +18 -0
  57. package/server/services/websocket/tab-handlers.ts +44 -23
  58. package/server/services/websocket/terminal-handlers.ts +40 -4
  59. 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
- session.executePrompt(msg.data.prompt, msg.data.attachments, { sandboxed, workingDir: worktreeDir });
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
- try {
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
  */