mstro-app 0.3.4 → 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 (68) hide show
  1. package/bin/mstro.js +15 -2
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +5 -10
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/improvisation-session-manager.d.ts +4 -0
  6. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  7. package/dist/server/cli/improvisation-session-manager.js +39 -1
  8. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  9. package/dist/server/services/platform.d.ts.map +1 -1
  10. package/dist/server/services/platform.js +2 -13
  11. package/dist/server/services/platform.js.map +1 -1
  12. package/dist/server/services/terminal/pty-manager.d.ts +19 -0
  13. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  14. package/dist/server/services/terminal/pty-manager.js +48 -1
  15. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  16. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  17. package/dist/server/services/websocket/file-explorer-handlers.js +17 -1
  18. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  19. package/dist/server/services/websocket/file-upload-handler.d.ts +44 -0
  20. package/dist/server/services/websocket/file-upload-handler.d.ts.map +1 -0
  21. package/dist/server/services/websocket/file-upload-handler.js +185 -0
  22. package/dist/server/services/websocket/file-upload-handler.js.map +1 -0
  23. package/dist/server/services/websocket/git-handlers.d.ts +1 -1
  24. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  25. package/dist/server/services/websocket/git-handlers.js +3 -3
  26. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  27. package/dist/server/services/websocket/git-worktree-handlers.d.ts +1 -1
  28. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  29. package/dist/server/services/websocket/git-worktree-handlers.js +40 -2
  30. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  31. package/dist/server/services/websocket/handler-context.d.ts +3 -0
  32. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  33. package/dist/server/services/websocket/handler.d.ts +4 -0
  34. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  35. package/dist/server/services/websocket/handler.js +31 -0
  36. package/dist/server/services/websocket/handler.js.map +1 -1
  37. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  38. package/dist/server/services/websocket/session-handlers.js +69 -20
  39. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  40. package/dist/server/services/websocket/session-registry.d.ts +6 -0
  41. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  42. package/dist/server/services/websocket/session-registry.js +16 -0
  43. package/dist/server/services/websocket/session-registry.js.map +1 -1
  44. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  45. package/dist/server/services/websocket/tab-handlers.js +33 -24
  46. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  47. package/dist/server/services/websocket/terminal-handlers.d.ts +4 -0
  48. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
  49. package/dist/server/services/websocket/terminal-handlers.js +35 -4
  50. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  51. package/dist/server/services/websocket/types.d.ts +2 -2
  52. package/dist/server/services/websocket/types.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/server/cli/headless/claude-invoker.ts +5 -11
  55. package/server/cli/improvisation-session-manager.ts +42 -1
  56. package/server/services/platform.ts +2 -12
  57. package/server/services/terminal/pty-manager.ts +57 -2
  58. package/server/services/websocket/file-explorer-handlers.ts +16 -1
  59. package/server/services/websocket/file-upload-handler.ts +259 -0
  60. package/server/services/websocket/git-handlers.ts +3 -3
  61. package/server/services/websocket/git-worktree-handlers.ts +47 -3
  62. package/server/services/websocket/handler-context.ts +3 -0
  63. package/server/services/websocket/handler.ts +33 -0
  64. package/server/services/websocket/session-handlers.ts +79 -20
  65. package/server/services/websocket/session-registry.ts +18 -0
  66. package/server/services/websocket/tab-handlers.ts +44 -23
  67. package/server/services/websocket/terminal-handlers.ts +40 -4
  68. package/server/services/websocket/types.ts +15 -2
@@ -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
  */
@@ -7,6 +7,43 @@ import type { HandlerContext } from './handler-context.js';
7
7
  import { buildOutputHistory, setupSessionListeners } from './session-handlers.js';
8
8
  import type { WebSocketMessage, WSContext } from './types.js';
9
9
 
10
+ function buildActiveTabData(
11
+ regTab: { tabName: string; createdAt: string; order: number; hasUnviewedCompletion?: boolean; sessionId: string },
12
+ session: ImprovisationSessionManager,
13
+ worktreePath: string | undefined,
14
+ worktreeBranch: string | undefined,
15
+ ): Record<string, unknown> {
16
+ return {
17
+ tabName: regTab.tabName,
18
+ createdAt: regTab.createdAt,
19
+ order: regTab.order,
20
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
21
+ sessionInfo: session.getSessionInfo(),
22
+ isExecuting: session.isExecuting,
23
+ outputHistory: buildOutputHistory(session),
24
+ executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
25
+ ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
26
+ ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
27
+ };
28
+ }
29
+
30
+ function buildInactiveTabData(
31
+ regTab: { tabName: string; createdAt: string; order: number; hasUnviewedCompletion?: boolean; sessionId: string },
32
+ worktreePath: string | undefined,
33
+ worktreeBranch: string | undefined,
34
+ ): Record<string, unknown> {
35
+ return {
36
+ tabName: regTab.tabName,
37
+ createdAt: regTab.createdAt,
38
+ order: regTab.order,
39
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
40
+ sessionId: regTab.sessionId,
41
+ isExecuting: false,
42
+ outputHistory: [],
43
+ ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
44
+ };
45
+ }
46
+
10
47
  export function handleGetActiveTabs(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
11
48
  const registry = ctx.getRegistry(workingDir);
12
49
  const allTabs = registry.getAllTabs();
@@ -14,29 +51,11 @@ export function handleGetActiveTabs(ctx: HandlerContext, ws: WSContext, workingD
14
51
  const tabs: Record<string, unknown> = {};
15
52
  for (const [tabId, regTab] of Object.entries(allTabs)) {
16
53
  const session = ctx.sessions.get(regTab.sessionId);
17
- if (session) {
18
- tabs[tabId] = {
19
- tabName: regTab.tabName,
20
- createdAt: regTab.createdAt,
21
- order: regTab.order,
22
- hasUnviewedCompletion: regTab.hasUnviewedCompletion,
23
- sessionInfo: session.getSessionInfo(),
24
- isExecuting: session.isExecuting,
25
- outputHistory: buildOutputHistory(session),
26
- executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
27
- ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
28
- };
29
- } else {
30
- tabs[tabId] = {
31
- tabName: regTab.tabName,
32
- createdAt: regTab.createdAt,
33
- order: regTab.order,
34
- hasUnviewedCompletion: regTab.hasUnviewedCompletion,
35
- sessionId: regTab.sessionId,
36
- isExecuting: false,
37
- outputHistory: [],
38
- };
39
- }
54
+ const worktreePath = ctx.gitDirectories.get(tabId);
55
+ const worktreeBranch = ctx.gitBranches.get(tabId);
56
+ tabs[tabId] = session
57
+ ? buildActiveTabData(regTab, session, worktreePath, worktreeBranch)
58
+ : buildInactiveTabData(regTab, worktreePath, worktreeBranch);
40
59
  }
41
60
 
42
61
  ctx.send(ws, { type: 'activeTabs', data: { tabs } });
@@ -65,6 +84,8 @@ export function handleSyncPromptText(ctx: HandlerContext, _ws: WSContext, msg: W
65
84
  export function handleRemoveTab(ctx: HandlerContext, _ws: WSContext, tabId: string, workingDir: string): void {
66
85
  const registry = ctx.getRegistry(workingDir);
67
86
  registry.unregisterTab(tabId);
87
+ ctx.gitDirectories.delete(tabId);
88
+ ctx.gitBranches.delete(tabId);
68
89
 
69
90
  ctx.broadcastToAll({
70
91
  type: 'tabRemoved',
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
+ import { platform } from 'node:os';
4
5
  import { AnalyticsEvents, trackEvent } from '../analytics.js';
5
6
  import { getPTYManager } from '../terminal/pty-manager.js';
6
7
  import type { HandlerContext } from './handler-context.js';
@@ -58,7 +59,7 @@ function handleTerminalInit(
58
59
  setupTerminalBroadcastListeners(ctx, terminalId);
59
60
 
60
61
  try {
61
- const { shell, cwd, isReconnect } = ptyManager.create(
62
+ const { shell, cwd, isReconnect, platform } = ptyManager.create(
62
63
  terminalId,
63
64
  workingDir,
64
65
  cols || 80,
@@ -77,8 +78,17 @@ function handleTerminalInit(
77
78
  ctx.send(ws, {
78
79
  type: 'terminalReady',
79
80
  terminalId,
80
- data: { shell, cwd, isReconnect }
81
+ data: { shell, cwd, isReconnect, platform }
81
82
  });
83
+
84
+ // Send scrollback buffer so reconnecting clients see prior output
85
+ if (isReconnect) {
86
+ const scrollback = ptyManager.getScrollback(terminalId);
87
+ if (scrollback) {
88
+ ctx.send(ws, { type: 'terminalScrollback', terminalId, data: { scrollback } });
89
+ }
90
+ }
91
+
82
92
  trackEvent(AnalyticsEvents.TERMINAL_SESSION_CREATED, {
83
93
  shell,
84
94
  is_reconnect: isReconnect,
@@ -116,10 +126,17 @@ function handleTerminalReconnect(ctx: HandlerContext, ws: WSContext, terminalId:
116
126
  data: {
117
127
  shell: sessionInfo.shell,
118
128
  cwd: sessionInfo.cwd,
119
- isReconnect: true
129
+ isReconnect: true,
130
+ platform: platform(),
120
131
  }
121
132
  });
122
133
 
134
+ // Send scrollback buffer so reconnecting clients see prior output
135
+ const scrollback = ptyManager.getScrollback(terminalId);
136
+ if (scrollback) {
137
+ ctx.send(ws, { type: 'terminalScrollback', terminalId, data: { scrollback } });
138
+ }
139
+
123
140
  ptyManager.resize(terminalId, sessionInfo.cols, sessionInfo.rows);
124
141
  }
125
142
 
@@ -269,9 +286,28 @@ function setupTerminalBroadcastListeners(ctx: HandlerContext, terminalId: string
269
286
  /**
270
287
  * Clean up terminal subscribers for a disconnected WS context.
271
288
  * Called from handler.ts handleClose().
289
+ *
290
+ * After removing the ws, also cleans up any subscriber Sets that are now empty
291
+ * and their associated PTY event listeners — preventing stale state accumulation
292
+ * across repeated connect/disconnect cycles (WebSocket hiccups).
272
293
  */
273
294
  export function cleanupTerminalSubscribers(ctx: HandlerContext, ws: WSContext): void {
274
- for (const subs of ctx.terminalSubscribers.values()) {
295
+ const emptyTerminals: string[] = [];
296
+
297
+ for (const [terminalId, subs] of ctx.terminalSubscribers) {
275
298
  subs.delete(ws);
299
+ if (subs.size === 0) {
300
+ emptyTerminals.push(terminalId);
301
+ }
302
+ }
303
+
304
+ // Clean up empty subscriber sets and their PTY event listeners
305
+ for (const terminalId of emptyTerminals) {
306
+ ctx.terminalSubscribers.delete(terminalId);
307
+ const cleanup = ctx.terminalListenerCleanups.get(terminalId);
308
+ if (cleanup) {
309
+ cleanup();
310
+ ctx.terminalListenerCleanups.delete(terminalId);
311
+ }
276
312
  }
277
313
  }
@@ -86,6 +86,7 @@ export interface WebSocketMessage {
86
86
  // Worktree operations
87
87
  | 'gitWorktreeList'
88
88
  | 'gitWorktreeCreate'
89
+ | 'gitWorktreeCreateAndAssign'
89
90
  | 'gitWorktreeRemove'
90
91
  | 'tabWorktreeSwitch'
91
92
  | 'gitWorktreePush'
@@ -105,7 +106,12 @@ export interface WebSocketMessage {
105
106
  | 'markTabViewed'
106
107
  // Settings message types
107
108
  | 'getSettings'
108
- | 'updateSettings';
109
+ | 'updateSettings'
110
+ // File upload message types (chunked remote uploads)
111
+ | 'fileUploadStart'
112
+ | 'fileUploadChunk'
113
+ | 'fileUploadComplete'
114
+ | 'fileUploadCancel';
109
115
  tabId?: string;
110
116
  terminalId?: string;
111
117
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
@@ -157,6 +163,8 @@ export interface WebSocketResponse {
157
163
  | 'contentSearchComplete'
158
164
  | 'contentSearchError'
159
165
  | 'definitionResult'
166
+ | 'fileError'
167
+ | 'terminalScrollback'
160
168
  // Terminal sync response types
161
169
  | 'terminalCreated'
162
170
  | 'terminalClosed'
@@ -189,6 +197,7 @@ export interface WebSocketResponse {
189
197
  // Worktree response types
190
198
  | 'gitWorktreeListResult'
191
199
  | 'gitWorktreeCreated'
200
+ | 'gitWorktreeCreatedAndAssigned'
192
201
  | 'gitWorktreeRemoved'
193
202
  | 'tabWorktreeSwitched'
194
203
  | 'gitWorktreePushed'
@@ -209,7 +218,11 @@ export interface WebSocketResponse {
209
218
  | 'tabStateChanged'
210
219
  // Settings response types
211
220
  | 'settings'
212
- | 'settingsUpdated';
221
+ | 'settingsUpdated'
222
+ // File upload response types
223
+ | 'fileUploadAck'
224
+ | 'fileUploadReady'
225
+ | 'fileUploadError';
213
226
  tabId?: string;
214
227
  terminalId?: string;
215
228
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads