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
|
@@ -6,10 +6,11 @@ import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handl
|
|
|
6
6
|
import type { HandlerContext } from './handler-context.js';
|
|
7
7
|
import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
|
|
8
8
|
|
|
9
|
-
export function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): void {
|
|
10
|
-
const handlers: Record<string, () => void
|
|
9
|
+
export async function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
|
|
10
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
11
11
|
gitWorktreeList: () => handleGitWorktreeList(ctx, ws, tabId, gitDir),
|
|
12
12
|
gitWorktreeCreate: () => handleGitWorktreeCreate(ctx, ws, msg, tabId, gitDir),
|
|
13
|
+
gitWorktreeCreateAndAssign: () => handleGitWorktreeCreateAndAssign(ctx, ws, msg, tabId, gitDir, workingDir),
|
|
13
14
|
gitWorktreeRemove: () => handleGitWorktreeRemove(ctx, ws, msg, tabId, gitDir),
|
|
14
15
|
tabWorktreeSwitch: () => handleTabWorktreeSwitch(ctx, ws, msg, tabId, workingDir),
|
|
15
16
|
gitWorktreePush: () => handleGitWorktreePush(ctx, ws, msg, tabId, gitDir),
|
|
@@ -19,7 +20,7 @@ export function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg
|
|
|
19
20
|
gitMergeAbort: () => handleGitMergeAbort(ctx, ws, tabId, gitDir),
|
|
20
21
|
gitMergeComplete: () => handleGitMergeComplete(ctx, ws, msg, tabId, gitDir),
|
|
21
22
|
};
|
|
22
|
-
handlers[msg.type]?.();
|
|
23
|
+
await handlers[msg.type]?.();
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function applyWorktreePorcelainLine(line: string, worktrees: WorktreeInfo[], current: Partial<WorktreeInfo>): Partial<WorktreeInfo> {
|
|
@@ -90,6 +91,44 @@ async function handleGitWorktreeCreate(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
const { branchName, baseBranch, path: worktreePath } = msg.data || {};
|
|
97
|
+
if (!branchName) {
|
|
98
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const repoName = gitDir.split('/').pop() || 'repo';
|
|
103
|
+
const wtPath = worktreePath || join(dirname(gitDir), `${repoName}-worktrees`, branchName);
|
|
104
|
+
|
|
105
|
+
const args = ['worktree', 'add', wtPath, '-b', branchName, ...(baseBranch ? [baseBranch] : [])];
|
|
106
|
+
const result = await executeGitCommand(args, gitDir);
|
|
107
|
+
if (result.exitCode !== 0) {
|
|
108
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to create worktree' } });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const headResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], wtPath);
|
|
113
|
+
|
|
114
|
+
// Assign to tab
|
|
115
|
+
ctx.gitDirectories.set(tabId, wtPath);
|
|
116
|
+
ctx.gitBranches.set(tabId, branchName);
|
|
117
|
+
const registry = ctx.getRegistry(workingDir);
|
|
118
|
+
registry.updateTabWorktree(tabId, wtPath, branchName);
|
|
119
|
+
|
|
120
|
+
ctx.send(ws, {
|
|
121
|
+
type: 'gitWorktreeCreatedAndAssigned',
|
|
122
|
+
tabId,
|
|
123
|
+
data: { tabId, path: wtPath, branch: branchName, head: headResult.stdout.trim() },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
handleGitStatus(ctx, ws, tabId, wtPath);
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
93
132
|
async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
94
133
|
try {
|
|
95
134
|
const { path: wtPath, force, deleteBranch } = msg.data || {};
|
|
@@ -127,8 +166,11 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
127
166
|
try {
|
|
128
167
|
const { tabId: targetTabId, worktreePath } = msg.data || {};
|
|
129
168
|
const resolvedTabId = targetTabId || tabId;
|
|
169
|
+
const registry = ctx.getRegistry(workingDir);
|
|
130
170
|
if (!worktreePath) {
|
|
131
171
|
ctx.gitDirectories.delete(resolvedTabId);
|
|
172
|
+
ctx.gitBranches.delete(resolvedTabId);
|
|
173
|
+
registry.updateTabWorktree(resolvedTabId, null, null);
|
|
132
174
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: workingDir, branch: '' } });
|
|
133
175
|
handleGitStatus(ctx, ws, resolvedTabId, workingDir);
|
|
134
176
|
return;
|
|
@@ -138,6 +180,8 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
138
180
|
|
|
139
181
|
const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
140
182
|
const branch = branchResult.stdout.trim();
|
|
183
|
+
ctx.gitBranches.set(resolvedTabId, branch);
|
|
184
|
+
registry.updateTabWorktree(resolvedTabId, worktreePath, branch);
|
|
141
185
|
|
|
142
186
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
|
|
143
187
|
handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ChildProcess } from 'node:child_process';
|
|
5
5
|
import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
6
6
|
import type { AutocompleteService } from './autocomplete.js';
|
|
7
|
+
import type { FileUploadHandler } from './file-upload-handler.js';
|
|
7
8
|
import type { SessionRegistry } from './session-registry.js';
|
|
8
9
|
import type { WebSocketResponse, WSContext } from './types.js';
|
|
9
10
|
|
|
@@ -25,11 +26,13 @@ export interface HandlerContext {
|
|
|
25
26
|
connections: Map<WSContext, Map<string, string>>;
|
|
26
27
|
allConnections: Set<WSContext>;
|
|
27
28
|
gitDirectories: Map<string, string>;
|
|
29
|
+
gitBranches: Map<string, string>;
|
|
28
30
|
activeSearches: Map<string, ChildProcess>;
|
|
29
31
|
terminalSubscribers: Map<string, Set<WSContext>>;
|
|
30
32
|
terminalListenerCleanups: Map<string, () => void>;
|
|
31
33
|
autocompleteService: AutocompleteService;
|
|
32
34
|
usageReporter: UsageReporter | null;
|
|
35
|
+
fileUploadHandler: FileUploadHandler | null;
|
|
33
36
|
|
|
34
37
|
// Registry access
|
|
35
38
|
getRegistry(workingDir: string): SessionRegistry;
|
|
@@ -16,6 +16,7 @@ import type { ImprovisationSessionManager } from '../../cli/improvisation-sessio
|
|
|
16
16
|
import { captureException } from '../sentry.js';
|
|
17
17
|
import { AutocompleteService } from './autocomplete.js';
|
|
18
18
|
import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
|
|
19
|
+
import { FileUploadHandler } from './file-upload-handler.js';
|
|
19
20
|
import { handleGitMessage } from './git-handlers.js';
|
|
20
21
|
import type { HandlerContext, UsageReporter } from './handler-context.js';
|
|
21
22
|
import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistoricalSession } from './session-handlers.js';
|
|
@@ -34,11 +35,13 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
34
35
|
private frecencyPath: string;
|
|
35
36
|
usageReporter: UsageReporter | null = null;
|
|
36
37
|
gitDirectories: Map<string, string> = new Map();
|
|
38
|
+
gitBranches: Map<string, string> = new Map();
|
|
37
39
|
private sessionRegistry: SessionRegistry | null = null;
|
|
38
40
|
allConnections: Set<WSContext> = new Set();
|
|
39
41
|
activeSearches: Map<string, ChildProcess> = new Map();
|
|
40
42
|
terminalListenerCleanups: Map<string, () => void> = new Map();
|
|
41
43
|
terminalSubscribers: Map<string, Set<WSContext>> = new Map();
|
|
44
|
+
fileUploadHandler: FileUploadHandler | null = null;
|
|
42
45
|
|
|
43
46
|
constructor() {
|
|
44
47
|
this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
|
|
@@ -192,6 +195,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
192
195
|
case 'gitPushTag':
|
|
193
196
|
case 'gitWorktreeList':
|
|
194
197
|
case 'gitWorktreeCreate':
|
|
198
|
+
case 'gitWorktreeCreateAndAssign':
|
|
195
199
|
case 'gitWorktreeRemove':
|
|
196
200
|
case 'tabWorktreeSwitch':
|
|
197
201
|
case 'gitWorktreePush':
|
|
@@ -221,11 +225,40 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
221
225
|
return handleGetSettings(this, ws);
|
|
222
226
|
case 'updateSettings':
|
|
223
227
|
return handleUpdateSettings(this, ws, msg);
|
|
228
|
+
// File upload messages (chunked remote uploads)
|
|
229
|
+
case 'fileUploadStart':
|
|
230
|
+
case 'fileUploadChunk':
|
|
231
|
+
case 'fileUploadComplete':
|
|
232
|
+
case 'fileUploadCancel':
|
|
233
|
+
return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
|
|
224
234
|
default:
|
|
225
235
|
throw new Error(`Unknown message type: ${msg.type}`);
|
|
226
236
|
}
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
private handleFileUploadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
240
|
+
if (!this.fileUploadHandler) {
|
|
241
|
+
this.fileUploadHandler = new FileUploadHandler(workingDir);
|
|
242
|
+
}
|
|
243
|
+
const handler = this.fileUploadHandler;
|
|
244
|
+
const send = this.send.bind(this);
|
|
245
|
+
|
|
246
|
+
switch (msg.type) {
|
|
247
|
+
case 'fileUploadStart':
|
|
248
|
+
handler.handleUploadStart(ws, send, tabId, msg.data);
|
|
249
|
+
break;
|
|
250
|
+
case 'fileUploadChunk':
|
|
251
|
+
handler.handleUploadChunk(ws, send, tabId, msg.data);
|
|
252
|
+
break;
|
|
253
|
+
case 'fileUploadComplete':
|
|
254
|
+
handler.handleUploadComplete(ws, send, tabId, msg.data);
|
|
255
|
+
break;
|
|
256
|
+
case 'fileUploadCancel':
|
|
257
|
+
handler.handleUploadCancel(ws, send, tabId, msg.data);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
229
262
|
handleClose(ws: WSContext): void {
|
|
230
263
|
this.connections.delete(ws);
|
|
231
264
|
this.allConnections.delete(ws);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
6
|
+
import { type FileAttachment, ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
7
7
|
import { getModel } from '../settings.js';
|
|
8
8
|
import type { HandlerContext } from './handler-context.js';
|
|
9
9
|
import type { SessionRegistry } from './session-registry.js';
|
|
@@ -128,6 +128,29 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
|
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Merge pre-uploaded files (from chunked upload) with any inline attachments */
|
|
132
|
+
function mergePreUploadedAttachments(ctx: HandlerContext, tabId: string, inlineAttachments?: FileAttachment[]): FileAttachment[] | undefined {
|
|
133
|
+
if (!ctx.fileUploadHandler) return inlineAttachments;
|
|
134
|
+
const preUploaded = ctx.fileUploadHandler.getAndClearCompletedUploads(tabId);
|
|
135
|
+
if (preUploaded.length === 0) return inlineAttachments;
|
|
136
|
+
|
|
137
|
+
const merged: (FileAttachment & { _preUploaded?: boolean })[] = [...(inlineAttachments || [])];
|
|
138
|
+
for (const upload of preUploaded) {
|
|
139
|
+
const alreadyIncluded = merged.some(a => a.fileName === upload.fileName);
|
|
140
|
+
if (!alreadyIncluded) {
|
|
141
|
+
merged.push({
|
|
142
|
+
fileName: upload.fileName,
|
|
143
|
+
filePath: upload.filePath,
|
|
144
|
+
content: '',
|
|
145
|
+
isImage: upload.isImage,
|
|
146
|
+
mimeType: upload.mimeType,
|
|
147
|
+
_preUploaded: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'control' | 'view'): void {
|
|
132
155
|
switch (msg.type) {
|
|
133
156
|
case 'execute': {
|
|
@@ -135,7 +158,8 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
135
158
|
const session = requireSession(ctx, ws, tabId);
|
|
136
159
|
const sandboxed = permission === 'control' || permission === 'view';
|
|
137
160
|
const worktreeDir = ctx.gitDirectories.get(tabId);
|
|
138
|
-
|
|
161
|
+
const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
|
|
162
|
+
session.executePrompt(msg.data.prompt, attachments, { sandboxed, workingDir: worktreeDir });
|
|
139
163
|
break;
|
|
140
164
|
}
|
|
141
165
|
case 'cancel': {
|
|
@@ -206,6 +230,47 @@ export function handleHistoryMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
206
230
|
}
|
|
207
231
|
}
|
|
208
232
|
|
|
233
|
+
function tryResumeFromDisk(
|
|
234
|
+
ctx: HandlerContext,
|
|
235
|
+
ws: WSContext,
|
|
236
|
+
tabId: string,
|
|
237
|
+
workingDir: string,
|
|
238
|
+
registrySessionId: string,
|
|
239
|
+
tabMap: Map<string, string> | undefined,
|
|
240
|
+
registry: SessionRegistry
|
|
241
|
+
): boolean {
|
|
242
|
+
try {
|
|
243
|
+
const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
|
|
244
|
+
setupSessionListeners(ctx, diskSession, ws, tabId);
|
|
245
|
+
const diskSessionId = diskSession.getSessionInfo().sessionId;
|
|
246
|
+
ctx.sessions.set(diskSessionId, diskSession);
|
|
247
|
+
if (tabMap) tabMap.set(tabId, diskSessionId);
|
|
248
|
+
registry.touchTab(tabId);
|
|
249
|
+
|
|
250
|
+
// Restore worktree state from registry
|
|
251
|
+
const regTab = registry.getTab(tabId);
|
|
252
|
+
if (regTab?.worktreePath && !ctx.gitDirectories.has(tabId)) {
|
|
253
|
+
ctx.gitDirectories.set(tabId, regTab.worktreePath);
|
|
254
|
+
if (regTab.worktreeBranch) ctx.gitBranches.set(tabId, regTab.worktreeBranch);
|
|
255
|
+
}
|
|
256
|
+
const worktreePath = ctx.gitDirectories.get(tabId);
|
|
257
|
+
const worktreeBranch = ctx.gitBranches.get(tabId);
|
|
258
|
+
|
|
259
|
+
ctx.send(ws, {
|
|
260
|
+
type: 'tabInitialized',
|
|
261
|
+
tabId,
|
|
262
|
+
data: {
|
|
263
|
+
...diskSession.getSessionInfo(),
|
|
264
|
+
outputHistory: buildOutputHistory(diskSession),
|
|
265
|
+
...(worktreePath ? { worktreePath, worktreeBranch } : {}),
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
209
274
|
export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string, tabName?: string): Promise<void> {
|
|
210
275
|
const tabMap = ctx.connections.get(ws);
|
|
211
276
|
const registry = ctx.getRegistry(workingDir);
|
|
@@ -229,25 +294,8 @@ export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: s
|
|
|
229
294
|
return;
|
|
230
295
|
}
|
|
231
296
|
|
|
232
|
-
|
|
233
|
-
const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
|
|
234
|
-
setupSessionListeners(ctx, diskSession, ws, tabId);
|
|
235
|
-
const diskSessionId = diskSession.getSessionInfo().sessionId;
|
|
236
|
-
ctx.sessions.set(diskSessionId, diskSession);
|
|
237
|
-
if (tabMap) tabMap.set(tabId, diskSessionId);
|
|
238
|
-
registry.touchTab(tabId);
|
|
239
|
-
|
|
240
|
-
ctx.send(ws, {
|
|
241
|
-
type: 'tabInitialized',
|
|
242
|
-
tabId,
|
|
243
|
-
data: {
|
|
244
|
-
...diskSession.getSessionInfo(),
|
|
245
|
-
outputHistory: buildOutputHistory(diskSession),
|
|
246
|
-
}
|
|
247
|
-
});
|
|
297
|
+
if (tryResumeFromDisk(ctx, ws, tabId, workingDir, registrySessionId, tabMap, registry)) {
|
|
248
298
|
return;
|
|
249
|
-
} catch {
|
|
250
|
-
// Disk session not found — fall through to create new
|
|
251
299
|
}
|
|
252
300
|
}
|
|
253
301
|
|
|
@@ -352,12 +400,22 @@ function reattachSession(
|
|
|
352
400
|
if (tabMap) tabMap.set(tabId, sessionId);
|
|
353
401
|
registry.touchTab(tabId);
|
|
354
402
|
|
|
403
|
+
// Restore worktree state from registry if not already in memory
|
|
404
|
+
const regTab = registry.getTab(tabId);
|
|
405
|
+
if (regTab?.worktreePath && !ctx.gitDirectories.has(tabId)) {
|
|
406
|
+
ctx.gitDirectories.set(tabId, regTab.worktreePath);
|
|
407
|
+
if (regTab.worktreeBranch) ctx.gitBranches.set(tabId, regTab.worktreeBranch);
|
|
408
|
+
}
|
|
409
|
+
|
|
355
410
|
const outputHistory = buildOutputHistory(session);
|
|
356
411
|
|
|
357
412
|
const executionEvents = session.isExecuting
|
|
358
413
|
? session.getExecutionEventLog()
|
|
359
414
|
: undefined;
|
|
360
415
|
|
|
416
|
+
const worktreePath = ctx.gitDirectories.get(tabId);
|
|
417
|
+
const worktreeBranch = ctx.gitBranches.get(tabId);
|
|
418
|
+
|
|
361
419
|
ctx.send(ws, {
|
|
362
420
|
type: 'tabInitialized',
|
|
363
421
|
tabId,
|
|
@@ -367,6 +425,7 @@ function reattachSession(
|
|
|
367
425
|
isExecuting: session.isExecuting,
|
|
368
426
|
executionEvents,
|
|
369
427
|
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
428
|
+
...(worktreePath ? { worktreePath, worktreeBranch } : {}),
|
|
370
429
|
}
|
|
371
430
|
});
|
|
372
431
|
}
|
|
@@ -21,6 +21,8 @@ export interface RegisteredTab {
|
|
|
21
21
|
lastActivityAt: string
|
|
22
22
|
order: number
|
|
23
23
|
hasUnviewedCompletion: boolean
|
|
24
|
+
worktreePath?: string
|
|
25
|
+
worktreeBranch?: string
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface RegistryData {
|
|
@@ -167,6 +169,22 @@ export class SessionRegistry {
|
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Update worktree assignment for a tab. Pass null to clear.
|
|
174
|
+
*/
|
|
175
|
+
updateTabWorktree(tabId: string, worktreePath: string | null, worktreeBranch: string | null): void {
|
|
176
|
+
if (this.data.tabs[tabId]) {
|
|
177
|
+
if (worktreePath) {
|
|
178
|
+
this.data.tabs[tabId].worktreePath = worktreePath
|
|
179
|
+
this.data.tabs[tabId].worktreeBranch = worktreeBranch || undefined
|
|
180
|
+
} else {
|
|
181
|
+
delete this.data.tabs[tabId].worktreePath
|
|
182
|
+
delete this.data.tabs[tabId].worktreeBranch
|
|
183
|
+
}
|
|
184
|
+
this.save()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
/**
|
|
171
189
|
* Reorder tabs. Accepts an ordered array of tabIds and reassigns order values.
|
|
172
190
|
*/
|
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
@@ -158,6 +164,7 @@ export interface WebSocketResponse {
|
|
|
158
164
|
| 'contentSearchError'
|
|
159
165
|
| 'definitionResult'
|
|
160
166
|
| 'fileError'
|
|
167
|
+
| 'terminalScrollback'
|
|
161
168
|
// Terminal sync response types
|
|
162
169
|
| 'terminalCreated'
|
|
163
170
|
| 'terminalClosed'
|
|
@@ -190,6 +197,7 @@ export interface WebSocketResponse {
|
|
|
190
197
|
// Worktree response types
|
|
191
198
|
| 'gitWorktreeListResult'
|
|
192
199
|
| 'gitWorktreeCreated'
|
|
200
|
+
| 'gitWorktreeCreatedAndAssigned'
|
|
193
201
|
| 'gitWorktreeRemoved'
|
|
194
202
|
| 'tabWorktreeSwitched'
|
|
195
203
|
| 'gitWorktreePushed'
|
|
@@ -210,7 +218,11 @@ export interface WebSocketResponse {
|
|
|
210
218
|
| 'tabStateChanged'
|
|
211
219
|
// Settings response types
|
|
212
220
|
| 'settings'
|
|
213
|
-
| 'settingsUpdated'
|
|
221
|
+
| 'settingsUpdated'
|
|
222
|
+
// File upload response types
|
|
223
|
+
| 'fileUploadAck'
|
|
224
|
+
| 'fileUploadReady'
|
|
225
|
+
| 'fileUploadError';
|
|
214
226
|
tabId?: string;
|
|
215
227
|
terminalId?: string;
|
|
216
228
|
// biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
|