mstro-app 0.3.5 → 0.3.7

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 (69) hide show
  1. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker.js +15 -16
  3. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  4. package/dist/server/cli/headless/runner.d.ts +6 -1
  5. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  6. package/dist/server/cli/headless/runner.js +20 -10
  7. package/dist/server/cli/headless/runner.js.map +1 -1
  8. package/dist/server/cli/improvisation-session-manager.d.ts +12 -1
  9. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  10. package/dist/server/cli/improvisation-session-manager.js +84 -4
  11. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  12. package/dist/server/services/platform.d.ts +6 -4
  13. package/dist/server/services/platform.d.ts.map +1 -1
  14. package/dist/server/services/platform.js +30 -11
  15. package/dist/server/services/platform.js.map +1 -1
  16. package/dist/server/services/terminal/pty-manager.d.ts +19 -0
  17. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  18. package/dist/server/services/terminal/pty-manager.js +51 -2
  19. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  20. package/dist/server/services/websocket/file-upload-handler.d.ts +44 -0
  21. package/dist/server/services/websocket/file-upload-handler.d.ts.map +1 -0
  22. package/dist/server/services/websocket/file-upload-handler.js +185 -0
  23. package/dist/server/services/websocket/file-upload-handler.js.map +1 -0
  24. package/dist/server/services/websocket/git-handlers.d.ts +1 -1
  25. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  26. package/dist/server/services/websocket/git-handlers.js +3 -3
  27. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  28. package/dist/server/services/websocket/git-worktree-handlers.d.ts +1 -1
  29. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  30. package/dist/server/services/websocket/git-worktree-handlers.js +40 -2
  31. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  32. package/dist/server/services/websocket/handler-context.d.ts +3 -0
  33. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  34. package/dist/server/services/websocket/handler.d.ts +4 -0
  35. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  36. package/dist/server/services/websocket/handler.js +31 -0
  37. package/dist/server/services/websocket/handler.js.map +1 -1
  38. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  39. package/dist/server/services/websocket/session-handlers.js +69 -20
  40. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  41. package/dist/server/services/websocket/session-registry.d.ts +6 -0
  42. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  43. package/dist/server/services/websocket/session-registry.js +16 -0
  44. package/dist/server/services/websocket/session-registry.js.map +1 -1
  45. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  46. package/dist/server/services/websocket/tab-handlers.js +33 -24
  47. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  48. package/dist/server/services/websocket/terminal-handlers.d.ts +4 -0
  49. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
  50. package/dist/server/services/websocket/terminal-handlers.js +35 -4
  51. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  52. package/dist/server/services/websocket/types.d.ts +2 -2
  53. package/dist/server/services/websocket/types.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/server/cli/headless/claude-invoker.ts +12 -16
  56. package/server/cli/headless/runner.ts +17 -4
  57. package/server/cli/improvisation-session-manager.ts +92 -4
  58. package/server/services/platform.ts +29 -11
  59. package/server/services/terminal/pty-manager.ts +60 -3
  60. package/server/services/websocket/file-upload-handler.ts +259 -0
  61. package/server/services/websocket/git-handlers.ts +3 -3
  62. package/server/services/websocket/git-worktree-handlers.ts +47 -3
  63. package/server/services/websocket/handler-context.ts +3 -0
  64. package/server/services/websocket/handler.ts +33 -0
  65. package/server/services/websocket/session-handlers.ts +79 -20
  66. package/server/services/websocket/session-registry.ts +18 -0
  67. package/server/services/websocket/tab-handlers.ts +44 -23
  68. package/server/services/websocket/terminal-handlers.ts +40 -4
  69. package/server/services/websocket/types.ts +14 -2
@@ -132,6 +132,12 @@ export class ImprovisationSessionManager extends EventEmitter {
132
132
  private executionEventLog: Array<{ type: string; data: unknown; timestamp: number }> = [];
133
133
  /** Set by cancel() to signal the retry loop to exit */
134
134
  private _cancelled: boolean = false;
135
+ /** True when cancel() has already emitted movementComplete (prevents double-emit) */
136
+ private _cancelCompleteEmitted: boolean = false;
137
+ /** Current execution's user prompt (for cancel to build movement record) */
138
+ private _currentUserPrompt: string = '';
139
+ /** Current execution's sequence number (for cancel to build movement record) */
140
+ private _currentSequenceNumber: number = 0;
135
141
 
136
142
  /**
137
143
  * Resume from a historical session.
@@ -283,6 +289,13 @@ export class ImprovisationSessionManager extends EventEmitter {
283
289
 
284
290
  const paths: string[] = [];
285
291
  for (const attachment of attachments) {
292
+ // Pre-uploaded files are already on disk from chunked upload
293
+ if ((attachment as FileAttachment & { _preUploaded?: boolean })._preUploaded) {
294
+ if (existsSync(attachment.filePath)) {
295
+ paths.push(attachment.filePath);
296
+ }
297
+ continue;
298
+ }
286
299
  const filePath = join(attachDir, attachment.fileName);
287
300
  try {
288
301
  // All paste content arrives as base64 — decode to binary
@@ -321,10 +334,13 @@ export class ImprovisationSessionManager extends EventEmitter {
321
334
  const _execStart = Date.now();
322
335
  this._isExecuting = true;
323
336
  this._cancelled = false;
337
+ this._cancelCompleteEmitted = false;
324
338
  this._executionStartTimestamp = _execStart;
325
339
  this.executionEventLog = [];
326
340
 
327
341
  const sequenceNumber = this.history.movements.length + 1;
342
+ this._currentUserPrompt = userPrompt;
343
+ this._currentSequenceNumber = sequenceNumber;
328
344
  this.emit('onMovementStart', sequenceNumber, userPrompt);
329
345
  trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
330
346
  prompt_length: userPrompt.length,
@@ -417,6 +433,13 @@ export class ImprovisationSessionManager extends EventEmitter {
417
433
  this.executionEventLog = [];
418
434
  this.currentRunner = null;
419
435
 
436
+ // If cancel() already emitted movementComplete, just clean up state —
437
+ // don't double-emit or double-persist.
438
+ if (this._cancelCompleteEmitted) {
439
+ const existing = this.history.movements.find(m => m.sequenceNumber === sequenceNumber);
440
+ if (existing) return existing;
441
+ }
442
+
420
443
  const cancelledMovement: MovementRecord = {
421
444
  id: `prompt-${sequenceNumber}`,
422
445
  sequenceNumber,
@@ -485,16 +508,50 @@ export class ImprovisationSessionManager extends EventEmitter {
485
508
  return result;
486
509
  }
487
510
 
511
+ /** MIME types that the Claude API can accept as image content blocks */
512
+ private static readonly SUPPORTED_IMAGE_MIMES = new Set([
513
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
514
+ ]);
515
+
516
+ /** Hydrate pre-uploaded images from disk and downgrade unsupported formats */
517
+ private hydrateAndFilterAttachments(attachments: FileAttachment[]): void {
518
+ for (const attachment of attachments) {
519
+ // Pre-uploaded images need their content read from disk
520
+ const preUploaded = (attachment as FileAttachment & { _preUploaded?: boolean })._preUploaded;
521
+ if (preUploaded && attachment.isImage && !attachment.content && existsSync(attachment.filePath)) {
522
+ try {
523
+ attachment.content = readFileSync(attachment.filePath).toString('base64');
524
+ } catch (err) {
525
+ console.error(`Failed to read pre-uploaded image ${attachment.filePath}:`, err);
526
+ attachment.isImage = false;
527
+ }
528
+ }
529
+
530
+ // Downgrade unsupported image formats (SVG, BMP, TIFF, ICO, etc.) to text attachments
531
+ if (attachment.isImage) {
532
+ const mime = (attachment.mimeType || '').toLowerCase();
533
+ if (mime && !ImprovisationSessionManager.SUPPORTED_IMAGE_MIMES.has(mime)) {
534
+ attachment.isImage = false;
535
+ }
536
+ }
537
+ }
538
+ }
539
+
488
540
  /** Prepare prompt with attachments and limit image count */
489
541
  private preparePromptAndAttachments(
490
542
  userPrompt: string,
491
543
  attachments: FileAttachment[] | undefined,
492
544
  ): { prompt: string; imageAttachments: FileAttachment[] | undefined } {
545
+ if (attachments) {
546
+ this.hydrateAndFilterAttachments(attachments);
547
+ }
548
+
493
549
  const diskPaths = attachments ? this.persistAttachments(attachments) : [];
494
550
  const prompt = this.buildPromptWithAttachments(userPrompt, attachments, diskPaths);
495
551
 
496
552
  const MAX_IMAGE_ATTACHMENTS = 20;
497
- const allImages = attachments?.filter(a => a.isImage);
553
+ // Only include images that have valid content
554
+ const allImages = attachments?.filter(a => a.isImage && a.content);
498
555
  let imageAttachments = allImages;
499
556
  if (allImages && allImages.length > MAX_IMAGE_ATTACHMENTS) {
500
557
  imageAttachments = allImages.slice(-MAX_IMAGE_ATTACHMENTS);
@@ -1469,16 +1526,47 @@ export class ImprovisationSessionManager extends EventEmitter {
1469
1526
  }
1470
1527
 
1471
1528
  /**
1472
- * Cancel current execution
1529
+ * Cancel current execution — immediately emits movementComplete so the web
1530
+ * gets instant feedback, then cleans up the process tree in the background.
1473
1531
  */
1474
1532
  cancel(): void {
1475
1533
  this._cancelled = true;
1534
+
1476
1535
  if (this.currentRunner) {
1477
1536
  this.currentRunner.cleanup();
1478
1537
  this.currentRunner = null;
1479
- this.queueOutput('\n⚠ Execution cancelled\n');
1480
- this.flushOutputQueue();
1481
1538
  }
1539
+
1540
+ // Emit movementComplete immediately so the web UI updates without waiting
1541
+ // for the process tree to fully die (SIGTERM → SIGKILL can take up to 5s).
1542
+ if (this._isExecuting && !this._cancelCompleteEmitted) {
1543
+ this._cancelCompleteEmitted = true;
1544
+ const execStart = this._executionStartTimestamp || Date.now();
1545
+ this._isExecuting = false;
1546
+ this._executionStartTimestamp = undefined;
1547
+
1548
+ const cancelledMovement: MovementRecord = {
1549
+ id: `prompt-${this._currentSequenceNumber}`,
1550
+ sequenceNumber: this._currentSequenceNumber,
1551
+ userPrompt: this._currentUserPrompt,
1552
+ timestamp: new Date().toISOString(),
1553
+ tokensUsed: 0,
1554
+ summary: '',
1555
+ filesModified: [],
1556
+ errorOutput: 'Execution cancelled by user',
1557
+ durationMs: Date.now() - execStart,
1558
+ };
1559
+ this.persistMovement(cancelledMovement);
1560
+
1561
+ const fallbackResult = {
1562
+ completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
1563
+ output: '', exitCode: 1, signalName: 'SIGTERM',
1564
+ } as HeadlessRunResult;
1565
+ this.emitMovementComplete(cancelledMovement, fallbackResult, execStart, this._currentSequenceNumber);
1566
+ }
1567
+
1568
+ this.queueOutput('\n⚠ Execution cancelled\n');
1569
+ this.flushOutputQueue();
1482
1570
  }
1483
1571
 
1484
1572
  /**
@@ -117,6 +117,9 @@ interface ConnectionCallbacks {
117
117
  /**
118
118
  * Platform WebSocket connection with token-based authentication
119
119
  */
120
+ /** Number of missed pongs before treating connection as dead */
121
+ const MAX_MISSED_PONGS = 2
122
+
120
123
  export class PlatformConnection {
121
124
  private ws: WebSocket | null = null
122
125
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
@@ -130,6 +133,7 @@ export class PlatformConnection {
130
133
  private isConnected = false
131
134
  private tokenRefreshInterval: ReturnType<typeof setInterval> | null = null
132
135
  private heartbeatInterval: ReturnType<typeof setInterval> | null = null
136
+ private missedPongs = 0
133
137
 
134
138
  constructor(
135
139
  workingDirectory: string,
@@ -184,19 +188,33 @@ export class PlatformConnection {
184
188
  }
185
189
 
186
190
  /**
187
- * Start heartbeat to keep connection alive and refresh server-side TTL
191
+ * Start heartbeat to keep connection alive and refresh server-side TTL.
192
+ * Tracks missed pongs — if the server doesn't respond to MAX_MISSED_PONGS
193
+ * consecutive pings, the connection is considered dead and force-closed
194
+ * to trigger reconnection.
188
195
  */
189
196
  private startHeartbeat(): void {
197
+ this.missedPongs = 0
190
198
  // Send ping every 2 minutes (server TTL is 5 minutes)
191
- this.heartbeatInterval = setInterval(() => {
192
- if (this.ws && this.isConnected) {
193
- try {
194
- this.ws.send(JSON.stringify({ type: 'ping' }))
195
- } catch {
196
- // Ignore send errors - will reconnect if disconnected
197
- }
198
- }
199
- }, 2 * 60 * 1000)
199
+ this.heartbeatInterval = setInterval(() => this.heartbeatTick(), 2 * 60 * 1000)
200
+ }
201
+
202
+ private heartbeatTick(): void {
203
+ if (!this.ws || !this.isConnected) return
204
+
205
+ if (this.missedPongs >= MAX_MISSED_PONGS) {
206
+ console.log(`[Platform] ${this.missedPongs} pongs missed — forcing reconnect`)
207
+ this.missedPongs = 0
208
+ this.stopHeartbeat()
209
+ try { this.ws.close() } catch { /* ignore */ }
210
+ return
211
+ }
212
+ this.missedPongs++
213
+ try {
214
+ this.ws.send(JSON.stringify({ type: 'ping' }))
215
+ } catch {
216
+ // Send failed — onclose will handle reconnect
217
+ }
200
218
  }
201
219
 
202
220
  /**
@@ -363,7 +381,7 @@ export class PlatformConnection {
363
381
  break
364
382
 
365
383
  case 'pong':
366
- // Heartbeat response, ignore
384
+ this.missedPongs = 0
367
385
  break
368
386
 
369
387
  default:
@@ -102,6 +102,47 @@ export function getPtyInstallInstructions(): string {
102
102
  // Import type separately for type-checking (doesn't require the module to load)
103
103
  type IPty = import('node-pty').IPty;
104
104
 
105
+ /**
106
+ * Fixed-size buffer that retains the most recent PTY output for replay on reconnect.
107
+ * Stores raw string chunks and evicts oldest data when the total exceeds maxBytes.
108
+ */
109
+ class ScrollbackBuffer {
110
+ private chunks: string[] = [];
111
+ private totalLength = 0;
112
+ private maxBytes: number;
113
+
114
+ constructor(maxBytes: number) {
115
+ this.maxBytes = maxBytes;
116
+ }
117
+
118
+ append(data: string): void {
119
+ this.chunks.push(data);
120
+ this.totalLength += data.length;
121
+ // Evict oldest chunks until under budget
122
+ while (this.totalLength > this.maxBytes && this.chunks.length > 1) {
123
+ const removed = this.chunks.shift()!;
124
+ this.totalLength -= removed.length;
125
+ }
126
+ // If a single chunk exceeds max, truncate from the front
127
+ if (this.totalLength > this.maxBytes && this.chunks.length === 1) {
128
+ const excess = this.totalLength - this.maxBytes;
129
+ this.chunks[0] = this.chunks[0].slice(excess);
130
+ this.totalLength = this.chunks[0].length;
131
+ }
132
+ }
133
+
134
+ getContents(): string {
135
+ return this.chunks.join('');
136
+ }
137
+
138
+ clear(): void {
139
+ this.chunks = [];
140
+ this.totalLength = 0;
141
+ }
142
+ }
143
+
144
+ const SCROLLBACK_MAX_BYTES = 256 * 1024; // 256KB
145
+
105
146
  export interface PTYSession {
106
147
  id: string;
107
148
  pty: IPty;
@@ -117,6 +158,8 @@ export interface PTYSession {
117
158
  // Output coalescing: buffer small chunks into fewer WS messages
118
159
  _outputBuffer: string;
119
160
  _outputTimer: ReturnType<typeof setTimeout> | null;
161
+ // Scrollback ring buffer for replay on reconnect
162
+ scrollback: ScrollbackBuffer;
120
163
  }
121
164
 
122
165
  /**
@@ -201,7 +244,7 @@ export class PTYManager extends EventEmitter {
201
244
  rows: number = 24,
202
245
  requestedShell?: string,
203
246
  options?: { sandboxed?: boolean }
204
- ): { shell: string; cwd: string; isReconnect: boolean } {
247
+ ): { shell: string; cwd: string; isReconnect: boolean; platform: string } {
205
248
  // Check if node-pty is available
206
249
  if (!pty) {
207
250
  throw new Error(`PTY_NOT_AVAILABLE:${getPtyInstallInstructions()}`);
@@ -221,6 +264,7 @@ export class PTYManager extends EventEmitter {
221
264
  shell: existingSession.shell,
222
265
  cwd: existingSession.cwd,
223
266
  isReconnect: true,
267
+ platform: platform(),
224
268
  };
225
269
  }
226
270
 
@@ -259,6 +303,7 @@ export class PTYManager extends EventEmitter {
259
303
  rows,
260
304
  _outputBuffer: '',
261
305
  _outputTimer: null,
306
+ scrollback: new ScrollbackBuffer(SCROLLBACK_MAX_BYTES),
262
307
  };
263
308
  this.terminals.set(terminalId, session);
264
309
 
@@ -267,7 +312,9 @@ export class PTYManager extends EventEmitter {
267
312
  // wraps echoed chars in multi-part ANSI sequences (RPROMPT, syntax highlighting).
268
313
  // A longer window on macOS ensures these multi-part sequences arrive as one chunk,
269
314
  // which the browser's predictive echo can match correctly.
270
- const OUTPUT_COALESCE_MS = platform() === 'darwin' ? 24 : 8;
315
+ // 32ms on macOS captures full zsh redraw cycles (RPROMPT + syntax highlighting)
316
+ // that 24ms often split across coalesce boundaries.
317
+ const OUTPUT_COALESCE_MS = platform() === 'darwin' ? 32 : 8;
271
318
  // High-water mark: flush immediately when buffer exceeds this size
272
319
  // to prevent unbounded memory growth during high-output commands (e.g. `yes`)
273
320
  const OUTPUT_HIGH_WATER = 64 * 1024; // 64KB
@@ -288,6 +335,7 @@ export class PTYManager extends EventEmitter {
288
335
  };
289
336
 
290
337
  ptyProcess.onData((data: string) => {
338
+ session.scrollback.append(data);
291
339
  session.lastActivityAt = Date.now();
292
340
  session._outputBuffer += data;
293
341
  // Flush immediately if buffer exceeds high-water mark
@@ -310,7 +358,7 @@ export class PTYManager extends EventEmitter {
310
358
  this.terminals.delete(terminalId);
311
359
  });
312
360
 
313
- return { shell: session.shell, cwd, isReconnect: false };
361
+ return { shell: session.shell, cwd, isReconnect: false, platform: platform() };
314
362
  } catch (error: unknown) {
315
363
  console.error(`[PTYManager] Failed to create terminal ${terminalId}:`, error);
316
364
  this.emit('error', terminalId, error instanceof Error ? error.message : 'Failed to create terminal');
@@ -387,6 +435,15 @@ export class PTYManager extends EventEmitter {
387
435
  }
388
436
  }
389
437
 
438
+ /**
439
+ * Get scrollback buffer contents for replay on reconnect
440
+ */
441
+ getScrollback(terminalId: string): string | null {
442
+ const session = this.terminals.get(terminalId);
443
+ if (!session) return null;
444
+ return session.scrollback.getContents();
445
+ }
446
+
390
447
  /**
391
448
  * Get terminal session info
392
449
  */
@@ -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