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.
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +15 -16
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +6 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +20 -10
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +12 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +84 -4
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/platform.d.ts +6 -4
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +30 -11
- package/dist/server/services/platform.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 +51 -2
- 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 +12 -16
- package/server/cli/headless/runner.ts +17 -4
- package/server/cli/improvisation-session-manager.ts +92 -4
- package/server/services/platform.ts +29 -11
- package/server/services/terminal/pty-manager.ts +60 -3
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|