mstro-app 0.2.0 → 0.3.0
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/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +79 -49
- package/bin/mstro.js +240 -37
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +133 -27
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +23 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +20 -1
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +30 -24
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +19 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +221 -29
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +0 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +13 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +50 -3
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -359
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +67 -2328
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +63 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +155 -31
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/runner.ts +25 -0
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +25 -0
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +33 -25
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +277 -30
- package/server/index.ts +0 -4
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/services/analytics.ts +13 -1
- package/server/services/platform.ts +12 -1
- package/server/services/terminal/pty-manager.ts +53 -3
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +83 -2678
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +135 -0
- package/bin/release.sh +0 -110
|
@@ -23,9 +23,9 @@ export {
|
|
|
23
23
|
readFileContent,
|
|
24
24
|
scanDirectoryRecursiveWithDepth
|
|
25
25
|
} from './file-utils.js';
|
|
26
|
-
export type { UsageReport, UsageReporter } from './handler.js';
|
|
27
26
|
// Main handler class
|
|
28
27
|
export { WebSocketImproviseHandler } from './handler.js';
|
|
28
|
+
export type { HandlerContext, UsageReport, UsageReporter } from './handler-context.js';
|
|
29
29
|
// Types
|
|
30
30
|
export type {
|
|
31
31
|
AutocompleteResult,
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
7
|
+
import { getModel } from '../settings.js';
|
|
8
|
+
import type { HandlerContext } from './handler-context.js';
|
|
9
|
+
import type { SessionRegistry } from './session-registry.js';
|
|
10
|
+
import type { WebSocketMessage, WSContext } from './types.js';
|
|
11
|
+
|
|
12
|
+
/** Convert tool history entries into OutputLine-compatible lines */
|
|
13
|
+
function convertToolHistoryToLines(tools: any[], ts: number): any[] {
|
|
14
|
+
const lines: any[] = [];
|
|
15
|
+
for (const tool of tools) {
|
|
16
|
+
lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
|
|
17
|
+
if (tool.result !== undefined) {
|
|
18
|
+
lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Convert a single movement record into OutputLine-compatible entries */
|
|
25
|
+
function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: any[]; assistantResponse?: string; errorOutput?: string; tokensUsed: number; durationMs?: number }): any[] {
|
|
26
|
+
const lines: any[] = [];
|
|
27
|
+
const ts = new Date(movement.timestamp).getTime();
|
|
28
|
+
|
|
29
|
+
lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
|
|
30
|
+
|
|
31
|
+
if (movement.thinkingOutput) {
|
|
32
|
+
lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (movement.toolUseHistory) {
|
|
36
|
+
lines.push(...convertToolHistoryToLines(movement.toolUseHistory, ts));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (movement.assistantResponse) {
|
|
40
|
+
lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (movement.errorOutput) {
|
|
44
|
+
lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const durationText = movement.durationMs
|
|
48
|
+
? `Completed in ${(movement.durationMs / 1000).toFixed(2)}s`
|
|
49
|
+
: 'Completed';
|
|
50
|
+
lines.push({ type: 'system', text: durationText, timestamp: ts });
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requireSession(ctx: HandlerContext, ws: WSContext, tabId: string): ImprovisationSessionManager {
|
|
55
|
+
const session = getSession(ctx, ws, tabId);
|
|
56
|
+
if (!session) throw new Error(`No session found for tab ${tabId}`);
|
|
57
|
+
return session;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getSession(ctx: HandlerContext, ws: WSContext, tabId: string): ImprovisationSessionManager | null {
|
|
61
|
+
const tabMap = ctx.connections.get(ws);
|
|
62
|
+
if (!tabMap) return null;
|
|
63
|
+
|
|
64
|
+
const sessionId = tabMap.get(tabId);
|
|
65
|
+
if (!sessionId) return null;
|
|
66
|
+
|
|
67
|
+
return ctx.sessions.get(sessionId) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildOutputHistory(session: ImprovisationSessionManager): any[] {
|
|
71
|
+
const history = session.getHistory();
|
|
72
|
+
return history.movements.flatMap(convertMovementToLines);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setupSessionListeners(ctx: HandlerContext, session: ImprovisationSessionManager, ws: WSContext, tabId: string): void {
|
|
76
|
+
session.removeAllListeners();
|
|
77
|
+
|
|
78
|
+
session.on('onOutput', (text: string) => {
|
|
79
|
+
ctx.send(ws, { type: 'output', tabId, data: { text, timestamp: Date.now() } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
session.on('onThinking', (text: string) => {
|
|
83
|
+
ctx.send(ws, { type: 'thinking', tabId, data: { text } });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
session.on('onMovementStart', (sequenceNumber: number, prompt: string) => {
|
|
87
|
+
ctx.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp } });
|
|
88
|
+
ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
session.on('onMovementComplete', (movement: any) => {
|
|
92
|
+
ctx.send(ws, { type: 'movementComplete', tabId, data: movement });
|
|
93
|
+
|
|
94
|
+
const registry = ctx.getRegistry('');
|
|
95
|
+
// Use a try/catch since getRegistry may not have been initialized with the right workingDir
|
|
96
|
+
try { registry.markTabUnviewed(tabId); } catch { /* ignore */ }
|
|
97
|
+
|
|
98
|
+
ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
|
|
99
|
+
|
|
100
|
+
if (ctx.usageReporter && movement.tokensUsed) {
|
|
101
|
+
ctx.usageReporter({
|
|
102
|
+
tokensUsed: movement.tokensUsed,
|
|
103
|
+
sessionId: session.getSessionInfo().sessionId,
|
|
104
|
+
movementId: `${movement.sequenceNumber}`
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
session.on('onMovementError', (error: Error) => {
|
|
110
|
+
ctx.send(ws, { type: 'movementError', tabId, data: { message: error.message } });
|
|
111
|
+
ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
session.on('onSessionUpdate', (history: any) => {
|
|
115
|
+
ctx.send(ws, { type: 'sessionUpdate', tabId, data: history });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
session.on('onPlanNeedsConfirmation', (plan: any) => {
|
|
119
|
+
ctx.send(ws, { type: 'approvalRequired', tabId, data: plan });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
session.on('onToolUse', (event: any) => {
|
|
123
|
+
ctx.send(ws, { type: 'toolUse', tabId, data: { ...event, timestamp: Date.now() } });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
session.on('onTokenUsage', (usage: { inputTokens: number; outputTokens: number }) => {
|
|
127
|
+
ctx.send(ws, { type: 'streamingTokens', tabId, data: usage });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'control' | 'view'): void {
|
|
132
|
+
switch (msg.type) {
|
|
133
|
+
case 'execute': {
|
|
134
|
+
if (!msg.data?.prompt) throw new Error('Prompt is required');
|
|
135
|
+
const session = requireSession(ctx, ws, tabId);
|
|
136
|
+
const sandboxed = permission === 'control' || permission === 'view';
|
|
137
|
+
const worktreeDir = ctx.gitDirectories.get(tabId);
|
|
138
|
+
session.executePrompt(msg.data.prompt, msg.data.attachments, { sandboxed, workingDir: worktreeDir });
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'cancel': {
|
|
142
|
+
const session = requireSession(ctx, ws, tabId);
|
|
143
|
+
session.cancel();
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case 'getHistory': {
|
|
147
|
+
const session = requireSession(ctx, ws, tabId);
|
|
148
|
+
ctx.send(ws, { type: 'history', tabId, data: session.getHistory() });
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case 'new': {
|
|
152
|
+
const oldSession = requireSession(ctx, ws, tabId);
|
|
153
|
+
const newSession = oldSession.startNewSession({ model: getModel() });
|
|
154
|
+
setupSessionListeners(ctx, newSession, ws, tabId);
|
|
155
|
+
const newSessionId = newSession.getSessionInfo().sessionId;
|
|
156
|
+
ctx.sessions.set(newSessionId, newSession);
|
|
157
|
+
const tabMap = ctx.connections.get(ws);
|
|
158
|
+
if (tabMap) tabMap.set(tabId, newSessionId);
|
|
159
|
+
const registry = ctx.getRegistry('');
|
|
160
|
+
try { registry.updateTabSession(tabId, newSessionId); } catch { /* ignore */ }
|
|
161
|
+
ctx.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'approve': {
|
|
165
|
+
const session = requireSession(ctx, ws, tabId);
|
|
166
|
+
(session as any).respondToApproval?.(true);
|
|
167
|
+
ctx.send(ws, { type: 'output', tabId, data: { text: '\n✅ Approved - proceeding with operation\n' } });
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'reject': {
|
|
171
|
+
const session = requireSession(ctx, ws, tabId);
|
|
172
|
+
(session as any).respondToApproval?.(false);
|
|
173
|
+
ctx.send(ws, { type: 'output', tabId, data: { text: '\n🚫 Rejected - operation cancelled\n' } });
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function handleHistoryMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
180
|
+
switch (msg.type) {
|
|
181
|
+
case 'getSessions': {
|
|
182
|
+
const result = getSessionsList(workingDir, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
|
|
183
|
+
ctx.send(ws, { type: 'sessions', tabId, data: result });
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'getSessionsCount':
|
|
187
|
+
ctx.send(ws, { type: 'sessionsCount', tabId, data: { total: getSessionsCount(workingDir) } });
|
|
188
|
+
break;
|
|
189
|
+
case 'getSessionById':
|
|
190
|
+
if (!msg.data?.sessionId) throw new Error('Session ID is required');
|
|
191
|
+
ctx.send(ws, { type: 'sessionData', tabId, data: getSessionById(workingDir, msg.data.sessionId) });
|
|
192
|
+
break;
|
|
193
|
+
case 'deleteSession':
|
|
194
|
+
if (!msg.data?.sessionId) throw new Error('Session ID is required');
|
|
195
|
+
ctx.send(ws, { type: 'sessionDeleted', tabId, data: deleteSession(workingDir, msg.data.sessionId) });
|
|
196
|
+
break;
|
|
197
|
+
case 'clearHistory':
|
|
198
|
+
ctx.send(ws, { type: 'historyCleared', tabId, data: clearAllSessions(workingDir) });
|
|
199
|
+
break;
|
|
200
|
+
case 'searchHistory': {
|
|
201
|
+
if (!msg.data?.query) throw new Error('Search query is required');
|
|
202
|
+
const result = searchSessions(workingDir, msg.data.query, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
|
|
203
|
+
ctx.send(ws, { type: 'searchResults', tabId, data: { ...result, query: msg.data.query } });
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string, tabName?: string): Promise<void> {
|
|
210
|
+
const tabMap = ctx.connections.get(ws);
|
|
211
|
+
const registry = ctx.getRegistry(workingDir);
|
|
212
|
+
|
|
213
|
+
// 1. Check per-connection map (same WS reconnect)
|
|
214
|
+
const existingSessionId = tabMap?.get(tabId);
|
|
215
|
+
if (existingSessionId) {
|
|
216
|
+
const existingSession = ctx.sessions.get(existingSessionId);
|
|
217
|
+
if (existingSession) {
|
|
218
|
+
reattachSession(ctx, existingSession, ws, tabId, registry);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. Check session registry (cross-connection reattach)
|
|
224
|
+
const registrySessionId = registry.getTabSession(tabId);
|
|
225
|
+
if (registrySessionId) {
|
|
226
|
+
const inMemorySession = ctx.sessions.get(registrySessionId);
|
|
227
|
+
if (inMemorySession) {
|
|
228
|
+
reattachSession(ctx, inMemorySession, ws, tabId, registry);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
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
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
} catch {
|
|
250
|
+
// Disk session not found — fall through to create new
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 3. Create new session
|
|
255
|
+
const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
|
|
256
|
+
setupSessionListeners(ctx, session, ws, tabId);
|
|
257
|
+
|
|
258
|
+
const sessionId = session.getSessionInfo().sessionId;
|
|
259
|
+
ctx.sessions.set(sessionId, session);
|
|
260
|
+
|
|
261
|
+
if (tabMap) {
|
|
262
|
+
tabMap.set(tabId, sessionId);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
registry.registerTab(tabId, sessionId, tabName);
|
|
266
|
+
const registeredTab = registry.getTab(tabId);
|
|
267
|
+
ctx.broadcastToAll({
|
|
268
|
+
type: 'tabCreated',
|
|
269
|
+
data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, sessionInfo: session.getSessionInfo() }
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
ctx.send(ws, {
|
|
273
|
+
type: 'tabInitialized',
|
|
274
|
+
tabId,
|
|
275
|
+
data: session.getSessionInfo()
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function resumeHistoricalSession(
|
|
280
|
+
ctx: HandlerContext,
|
|
281
|
+
ws: WSContext,
|
|
282
|
+
tabId: string,
|
|
283
|
+
workingDir: string,
|
|
284
|
+
historicalSessionId: string
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
const tabMap = ctx.connections.get(ws);
|
|
287
|
+
const registry = ctx.getRegistry(workingDir);
|
|
288
|
+
|
|
289
|
+
const existingSessionId = tabMap?.get(tabId);
|
|
290
|
+
if (existingSessionId) {
|
|
291
|
+
const existingSession = ctx.sessions.get(existingSessionId);
|
|
292
|
+
if (existingSession) {
|
|
293
|
+
reattachSession(ctx, existingSession, ws, tabId, registry);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const registrySessionId = registry.getTabSession(tabId);
|
|
299
|
+
if (registrySessionId) {
|
|
300
|
+
const inMemorySession = ctx.sessions.get(registrySessionId);
|
|
301
|
+
if (inMemorySession) {
|
|
302
|
+
reattachSession(ctx, inMemorySession, ws, tabId, registry);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let session: ImprovisationSessionManager;
|
|
308
|
+
let isNewSession = false;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
|
|
312
|
+
} catch (error: any) {
|
|
313
|
+
console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
|
|
314
|
+
session = new ImprovisationSessionManager({ workingDir, model: getModel() });
|
|
315
|
+
isNewSession = true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
setupSessionListeners(ctx, session, ws, tabId);
|
|
319
|
+
|
|
320
|
+
const sessionId = session.getSessionInfo().sessionId;
|
|
321
|
+
ctx.sessions.set(sessionId, session);
|
|
322
|
+
|
|
323
|
+
if (tabMap) {
|
|
324
|
+
tabMap.set(tabId, sessionId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
registry.registerTab(tabId, sessionId);
|
|
328
|
+
|
|
329
|
+
ctx.send(ws, {
|
|
330
|
+
type: 'tabInitialized',
|
|
331
|
+
tabId,
|
|
332
|
+
data: {
|
|
333
|
+
...session.getSessionInfo(),
|
|
334
|
+
outputHistory: buildOutputHistory(session),
|
|
335
|
+
resumeFailed: isNewSession,
|
|
336
|
+
originalSessionId: isNewSession ? historicalSessionId : undefined
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function reattachSession(
|
|
342
|
+
ctx: HandlerContext,
|
|
343
|
+
session: ImprovisationSessionManager,
|
|
344
|
+
ws: WSContext,
|
|
345
|
+
tabId: string,
|
|
346
|
+
registry: SessionRegistry
|
|
347
|
+
): void {
|
|
348
|
+
setupSessionListeners(ctx, session, ws, tabId);
|
|
349
|
+
|
|
350
|
+
const tabMap = ctx.connections.get(ws);
|
|
351
|
+
const sessionId = session.getSessionInfo().sessionId;
|
|
352
|
+
if (tabMap) tabMap.set(tabId, sessionId);
|
|
353
|
+
registry.touchTab(tabId);
|
|
354
|
+
|
|
355
|
+
const outputHistory = buildOutputHistory(session);
|
|
356
|
+
|
|
357
|
+
const executionEvents = session.isExecuting
|
|
358
|
+
? session.getExecutionEventLog()
|
|
359
|
+
: undefined;
|
|
360
|
+
|
|
361
|
+
ctx.send(ws, {
|
|
362
|
+
type: 'tabInitialized',
|
|
363
|
+
tabId,
|
|
364
|
+
data: {
|
|
365
|
+
...session.getSessionInfo(),
|
|
366
|
+
outputHistory,
|
|
367
|
+
isExecuting: session.isExecuting,
|
|
368
|
+
executionEvents,
|
|
369
|
+
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ============================================
|
|
375
|
+
// History persistence functions
|
|
376
|
+
// ============================================
|
|
377
|
+
|
|
378
|
+
function getSessionsCount(workingDir: string): number {
|
|
379
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
380
|
+
if (!existsSync(sessionsDir)) return 0;
|
|
381
|
+
return readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json')).length;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
|
|
385
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
386
|
+
|
|
387
|
+
if (!existsSync(sessionsDir)) {
|
|
388
|
+
return { sessions: [], total: 0, hasMore: false };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const historyFiles = readdirSync(sessionsDir)
|
|
392
|
+
.filter((name: string) => name.endsWith('.json'))
|
|
393
|
+
.sort((a: string, b: string) => {
|
|
394
|
+
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
395
|
+
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
396
|
+
return timestampB - timestampA;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const total = historyFiles.length;
|
|
400
|
+
const pageFiles = historyFiles.slice(offset, offset + limit);
|
|
401
|
+
|
|
402
|
+
const sessions = pageFiles.map((filename: string) => {
|
|
403
|
+
const historyPath = join(sessionsDir, filename);
|
|
404
|
+
try {
|
|
405
|
+
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
406
|
+
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
407
|
+
|
|
408
|
+
const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: any) => ({
|
|
409
|
+
userPrompt: m.userPrompt?.slice(0, 100) || ''
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
sessionId: historyData.sessionId,
|
|
414
|
+
startedAt: historyData.startedAt,
|
|
415
|
+
lastActivityAt: historyData.lastActivityAt,
|
|
416
|
+
totalTokens: historyData.totalTokens,
|
|
417
|
+
movementCount: historyData.movements?.length || 0,
|
|
418
|
+
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
419
|
+
movements: movementPreviews
|
|
420
|
+
};
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}).filter(Boolean);
|
|
425
|
+
|
|
426
|
+
return { sessions, total, hasMore: offset + limit < total };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function getSessionById(workingDir: string, sessionId: string): any {
|
|
430
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
431
|
+
if (!existsSync(sessionsDir)) return null;
|
|
432
|
+
|
|
433
|
+
const historyFiles = readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json'));
|
|
434
|
+
|
|
435
|
+
for (const filename of historyFiles) {
|
|
436
|
+
const historyPath = join(sessionsDir, filename);
|
|
437
|
+
try {
|
|
438
|
+
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
439
|
+
if (historyData.sessionId === sessionId) {
|
|
440
|
+
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
441
|
+
return {
|
|
442
|
+
sessionId: historyData.sessionId,
|
|
443
|
+
startedAt: historyData.startedAt,
|
|
444
|
+
lastActivityAt: historyData.lastActivityAt,
|
|
445
|
+
totalTokens: historyData.totalTokens,
|
|
446
|
+
movementCount: historyData.movements?.length || 0,
|
|
447
|
+
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
448
|
+
movements: historyData.movements || [],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
// Skip files that can't be parsed
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function deleteSession(workingDir: string, sessionId: string): { sessionId: string; success: boolean } {
|
|
460
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
461
|
+
if (!existsSync(sessionsDir)) return { sessionId, success: false };
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const historyFiles = readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json'));
|
|
465
|
+
|
|
466
|
+
for (const filename of historyFiles) {
|
|
467
|
+
const historyPath = join(sessionsDir, filename);
|
|
468
|
+
try {
|
|
469
|
+
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
470
|
+
if (historyData.sessionId === sessionId) {
|
|
471
|
+
unlinkSync(historyPath);
|
|
472
|
+
return { sessionId, success: true };
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
// Skip files that can't be parsed
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { sessionId, success: false };
|
|
480
|
+
} catch (error) {
|
|
481
|
+
console.error('[WebSocketImproviseHandler] Error deleting session:', error);
|
|
482
|
+
return { sessionId, success: false };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function clearAllSessions(workingDir: string): { success: boolean; deletedCount: number } {
|
|
487
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
488
|
+
if (!existsSync(sessionsDir)) return { success: true, deletedCount: 0 };
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const historyFiles = readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json'));
|
|
492
|
+
|
|
493
|
+
let deletedCount = 0;
|
|
494
|
+
for (const filename of historyFiles) {
|
|
495
|
+
const historyPath = join(sessionsDir, filename);
|
|
496
|
+
try {
|
|
497
|
+
unlinkSync(historyPath);
|
|
498
|
+
deletedCount++;
|
|
499
|
+
} catch {
|
|
500
|
+
// Skip files that can't be deleted
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return { success: true, deletedCount };
|
|
505
|
+
} catch (error) {
|
|
506
|
+
console.error('[WebSocketImproviseHandler] Error clearing sessions:', error);
|
|
507
|
+
return { success: false, deletedCount: 0 };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function movementMatchesQuery(movements: any[] | undefined, lowerQuery: string): boolean {
|
|
512
|
+
if (!movements) return false;
|
|
513
|
+
return movements.some((m: any) =>
|
|
514
|
+
m.userPrompt?.toLowerCase().includes(lowerQuery) ||
|
|
515
|
+
m.summary?.toLowerCase().includes(lowerQuery) ||
|
|
516
|
+
m.assistantResponse?.toLowerCase().includes(lowerQuery)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function buildSessionSummary(historyData: any): any {
|
|
521
|
+
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
522
|
+
const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: any) => ({
|
|
523
|
+
userPrompt: m.userPrompt?.slice(0, 100) || ''
|
|
524
|
+
}));
|
|
525
|
+
return {
|
|
526
|
+
sessionId: historyData.sessionId,
|
|
527
|
+
startedAt: historyData.startedAt,
|
|
528
|
+
lastActivityAt: historyData.lastActivityAt,
|
|
529
|
+
totalTokens: historyData.totalTokens,
|
|
530
|
+
movementCount: historyData.movements?.length || 0,
|
|
531
|
+
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
532
|
+
movements: movementPreviews
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
|
|
537
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
538
|
+
if (!existsSync(sessionsDir)) return { sessions: [], total: 0, hasMore: false };
|
|
539
|
+
|
|
540
|
+
const lowerQuery = query.toLowerCase();
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const historyFiles = readdirSync(sessionsDir)
|
|
544
|
+
.filter((name: string) => name.endsWith('.json'))
|
|
545
|
+
.sort((a: string, b: string) => {
|
|
546
|
+
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
547
|
+
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
548
|
+
return timestampB - timestampA;
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const allMatches: any[] = [];
|
|
552
|
+
for (const filename of historyFiles) {
|
|
553
|
+
try {
|
|
554
|
+
const content = readFileSync(join(sessionsDir, filename), 'utf-8');
|
|
555
|
+
const historyData = JSON.parse(content);
|
|
556
|
+
if (movementMatchesQuery(historyData.movements, lowerQuery)) {
|
|
557
|
+
allMatches.push(buildSessionSummary(historyData));
|
|
558
|
+
}
|
|
559
|
+
} catch {
|
|
560
|
+
// Skip files that can't be parsed
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const total = allMatches.length;
|
|
565
|
+
return {
|
|
566
|
+
sessions: allMatches.slice(offset, offset + limit),
|
|
567
|
+
total,
|
|
568
|
+
hasMore: offset + limit < total
|
|
569
|
+
};
|
|
570
|
+
} catch (error) {
|
|
571
|
+
console.error('[WebSocketImproviseHandler] Error searching sessions:', error);
|
|
572
|
+
return { sessions: [], total: 0, hasMore: false };
|
|
573
|
+
}
|
|
574
|
+
}
|