mstro-app 0.2.0 → 0.3.1

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 (153) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +79 -49
  4. package/bin/mstro.js +305 -39
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +137 -30
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +2 -2
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts +6 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +59 -4
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +20 -1
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +20 -2
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +224 -31
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/index.js +6 -4
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/mcp/bouncer-cli.js +53 -14
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +70 -7
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/security-audit.d.ts +3 -3
  37. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  38. package/dist/server/mcp/security-audit.js.map +1 -1
  39. package/dist/server/mcp/server.js +3 -2
  40. package/dist/server/mcp/server.js.map +1 -1
  41. package/dist/server/services/analytics.d.ts +2 -2
  42. package/dist/server/services/analytics.d.ts.map +1 -1
  43. package/dist/server/services/analytics.js +13 -1
  44. package/dist/server/services/analytics.js.map +1 -1
  45. package/dist/server/services/files.js +7 -7
  46. package/dist/server/services/files.js.map +1 -1
  47. package/dist/server/services/pathUtils.js +1 -1
  48. package/dist/server/services/pathUtils.js.map +1 -1
  49. package/dist/server/services/platform.d.ts +2 -2
  50. package/dist/server/services/platform.d.ts.map +1 -1
  51. package/dist/server/services/platform.js +13 -1
  52. package/dist/server/services/platform.js.map +1 -1
  53. package/dist/server/services/sentry.d.ts +1 -1
  54. package/dist/server/services/sentry.d.ts.map +1 -1
  55. package/dist/server/services/sentry.js.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.d.ts +12 -0
  57. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  58. package/dist/server/services/terminal/pty-manager.js +81 -6
  59. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  60. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  62. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  63. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  64. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  65. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  66. package/dist/server/services/websocket/file-utils.js +27 -8
  67. package/dist/server/services/websocket/file-utils.js.map +1 -1
  68. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  69. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/git-handlers.js +797 -0
  71. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  73. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  75. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  77. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  79. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  81. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  82. package/dist/server/services/websocket/handler-context.js +4 -0
  83. package/dist/server/services/websocket/handler-context.js.map +1 -0
  84. package/dist/server/services/websocket/handler.d.ts +27 -359
  85. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  86. package/dist/server/services/websocket/handler.js +68 -2329
  87. package/dist/server/services/websocket/handler.js.map +1 -1
  88. package/dist/server/services/websocket/index.d.ts +1 -1
  89. package/dist/server/services/websocket/index.d.ts.map +1 -1
  90. package/dist/server/services/websocket/index.js.map +1 -1
  91. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  92. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  93. package/dist/server/services/websocket/session-handlers.js +508 -0
  94. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  95. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  96. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  97. package/dist/server/services/websocket/settings-handlers.js +125 -0
  98. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  99. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  100. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  101. package/dist/server/services/websocket/tab-handlers.js +131 -0
  102. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  103. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  104. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  105. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  106. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  107. package/dist/server/services/websocket/types.d.ts +63 -2
  108. package/dist/server/services/websocket/types.d.ts.map +1 -1
  109. package/dist/server/utils/agent-manager.d.ts +22 -2
  110. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  111. package/dist/server/utils/agent-manager.js +2 -2
  112. package/dist/server/utils/agent-manager.js.map +1 -1
  113. package/dist/server/utils/port-manager.js.map +1 -1
  114. package/hooks/bouncer.sh +17 -3
  115. package/package.json +7 -3
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +172 -43
  118. package/server/cli/headless/mcp-config.ts +8 -8
  119. package/server/cli/headless/runner.ts +57 -4
  120. package/server/cli/headless/stall-assessor.ts +25 -0
  121. package/server/cli/headless/tool-watchdog.ts +33 -25
  122. package/server/cli/headless/types.ts +11 -2
  123. package/server/cli/improvisation-session-manager.ts +285 -37
  124. package/server/index.ts +15 -13
  125. package/server/mcp/README.md +59 -67
  126. package/server/mcp/bouncer-cli.ts +73 -20
  127. package/server/mcp/bouncer-integration.ts +99 -16
  128. package/server/mcp/security-audit.ts +4 -4
  129. package/server/mcp/server.ts +6 -5
  130. package/server/services/analytics.ts +16 -4
  131. package/server/services/files.ts +13 -13
  132. package/server/services/pathUtils.ts +2 -2
  133. package/server/services/platform.ts +17 -6
  134. package/server/services/sentry.ts +1 -1
  135. package/server/services/terminal/pty-manager.ts +88 -11
  136. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  137. package/server/services/websocket/file-utils.ts +28 -9
  138. package/server/services/websocket/git-handlers.ts +924 -0
  139. package/server/services/websocket/git-pr-handlers.ts +363 -0
  140. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  141. package/server/services/websocket/handler-context.ts +44 -0
  142. package/server/services/websocket/handler.ts +85 -2680
  143. package/server/services/websocket/index.ts +1 -1
  144. package/server/services/websocket/session-handlers.ts +575 -0
  145. package/server/services/websocket/settings-handlers.ts +150 -0
  146. package/server/services/websocket/tab-handlers.ts +150 -0
  147. package/server/services/websocket/terminal-handlers.ts +277 -0
  148. package/server/services/websocket/types.ts +137 -0
  149. package/server/utils/agent-manager.ts +6 -6
  150. package/server/utils/port-manager.ts +1 -1
  151. package/bin/release.sh +0 -110
  152. package/server/services/platform.test.ts +0 -1304
  153. package/server/services/websocket/handler.test.ts +0 -20
@@ -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,575 @@
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: Array<{ toolName: string; toolInput?: Record<string, unknown>; result?: string; isError?: boolean }>, ts: number): Array<Record<string, unknown>> {
14
+ const lines: Array<Record<string, unknown>> = [];
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?: Array<{ toolName: string; toolInput?: Record<string, unknown>; result?: string; isError?: boolean }>; assistantResponse?: string; errorOutput?: string; tokensUsed: number; durationMs?: number }): Array<Record<string, unknown>> {
26
+ const lines: Array<Record<string, unknown>> = [];
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): Array<Record<string, unknown>> {
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: Record<string, unknown>) => {
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 as number,
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: Record<string, unknown>) => {
115
+ ctx.send(ws, { type: 'sessionUpdate', tabId, data: history });
116
+ });
117
+
118
+ session.on('onPlanNeedsConfirmation', (plan: Record<string, unknown>) => {
119
+ ctx.send(ws, { type: 'approvalRequired', tabId, data: plan });
120
+ });
121
+
122
+ session.on('onToolUse', (event: Record<string, unknown>) => {
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.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.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: unknown) {
313
+ console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error instanceof Error ? error.message : String(error)}. 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: Array<Record<string, unknown> | null>; 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: Record<string, unknown>) => ({
409
+ userPrompt: (typeof m.userPrompt === 'string' ? 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): Record<string, unknown> | null {
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: Array<Record<string, unknown>> | undefined, lowerQuery: string): boolean {
512
+ if (!movements) return false;
513
+ return movements.some((m: Record<string, unknown>) =>
514
+ (typeof m.userPrompt === 'string' && m.userPrompt.toLowerCase().includes(lowerQuery)) ||
515
+ (typeof m.summary === 'string' && m.summary.toLowerCase().includes(lowerQuery)) ||
516
+ (typeof m.assistantResponse === 'string' && m.assistantResponse.toLowerCase().includes(lowerQuery))
517
+ );
518
+ }
519
+
520
+ function buildSessionSummary(historyData: Record<string, unknown>): Record<string, unknown> {
521
+ const movements = historyData.movements as Array<Record<string, unknown>> | undefined;
522
+ const firstPrompt = (typeof movements?.[0]?.userPrompt === 'string' ? movements[0].userPrompt : '') || '';
523
+ const movementPreviews = (movements || []).slice(0, 3).map((m: Record<string, unknown>) => ({
524
+ userPrompt: (typeof m.userPrompt === 'string' ? m.userPrompt : '').slice(0, 100) || ''
525
+ }));
526
+ return {
527
+ sessionId: historyData.sessionId,
528
+ startedAt: historyData.startedAt,
529
+ lastActivityAt: historyData.lastActivityAt,
530
+ totalTokens: historyData.totalTokens,
531
+ movementCount: movements?.length || 0,
532
+ title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
533
+ movements: movementPreviews
534
+ };
535
+ }
536
+
537
+ function searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: Array<Record<string, unknown>>; total: number; hasMore: boolean } {
538
+ const sessionsDir = join(workingDir, '.mstro', 'history');
539
+ if (!existsSync(sessionsDir)) return { sessions: [], total: 0, hasMore: false };
540
+
541
+ const lowerQuery = query.toLowerCase();
542
+
543
+ try {
544
+ const historyFiles = readdirSync(sessionsDir)
545
+ .filter((name: string) => name.endsWith('.json'))
546
+ .sort((a: string, b: string) => {
547
+ const timestampA = parseInt(a.replace('.json', ''), 10);
548
+ const timestampB = parseInt(b.replace('.json', ''), 10);
549
+ return timestampB - timestampA;
550
+ });
551
+
552
+ const allMatches: Array<Record<string, unknown>> = [];
553
+ for (const filename of historyFiles) {
554
+ try {
555
+ const content = readFileSync(join(sessionsDir, filename), 'utf-8');
556
+ const historyData = JSON.parse(content);
557
+ if (movementMatchesQuery(historyData.movements, lowerQuery)) {
558
+ allMatches.push(buildSessionSummary(historyData));
559
+ }
560
+ } catch {
561
+ // Skip files that can't be parsed
562
+ }
563
+ }
564
+
565
+ const total = allMatches.length;
566
+ return {
567
+ sessions: allMatches.slice(offset, offset + limit),
568
+ total,
569
+ hasMore: offset + limit < total
570
+ };
571
+ } catch (error) {
572
+ console.error('[WebSocketImproviseHandler] Error searching sessions:', error);
573
+ return { sessions: [], total: 0, hasMore: false };
574
+ }
575
+ }