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
@@ -0,0 +1,150 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { getSettings, setModel } from '../settings.js';
8
+ import type { HandlerContext } from './handler-context.js';
9
+ import type { WebSocketMessage, WSContext } from './types.js';
10
+
11
+ export function handleGetSettings(ctx: HandlerContext, ws: WSContext): void {
12
+ ctx.send(ws, { type: 'settings', data: getSettings() });
13
+ }
14
+
15
+ export function handleUpdateSettings(ctx: HandlerContext, _ws: WSContext, msg: WebSocketMessage): void {
16
+ if (msg.data?.model !== undefined) {
17
+ setModel(msg.data.model);
18
+ }
19
+ ctx.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
20
+ }
21
+
22
+ export async function generateNotificationSummary(
23
+ ctx: HandlerContext,
24
+ ws: WSContext,
25
+ tabId: string,
26
+ userPrompt: string,
27
+ output: string,
28
+ workingDir: string
29
+ ): Promise<void> {
30
+ try {
31
+ const tempDir = join(workingDir, '.mstro', 'tmp');
32
+ if (!existsSync(tempDir)) {
33
+ mkdirSync(tempDir, { recursive: true });
34
+ }
35
+
36
+ let truncatedOutput = output;
37
+ if (output.length > 4000) {
38
+ const firstPart = output.slice(0, 2000);
39
+ const lastPart = output.slice(-1500);
40
+ truncatedOutput = `${firstPart}\n\n... [output truncated] ...\n\n${lastPart}`;
41
+ }
42
+
43
+ const summaryPrompt = `You are generating a SHORT browser notification summary for a completed task.
44
+ The user ran a task and wants a brief notification to remind them what happened.
45
+
46
+ USER'S ORIGINAL PROMPT:
47
+ "${userPrompt}"
48
+
49
+ TASK OUTPUT (may be truncated):
50
+ ${truncatedOutput}
51
+
52
+ Generate a notification summary following these rules:
53
+ 1. Maximum 100 characters (this is a browser notification)
54
+ 2. Focus on the OUTCOME, not the process
55
+ 3. Be specific about what was accomplished
56
+ 4. Use past tense (e.g., "Fixed bug in auth.ts", "Added 3 new tests")
57
+ 5. If there was an error, mention it briefly
58
+ 6. No emojis, no markdown, just plain text
59
+
60
+ Respond with ONLY the summary text, nothing else.`;
61
+
62
+ const promptFile = join(tempDir, `notif-summary-${Date.now()}.txt`);
63
+ writeFileSync(promptFile, summaryPrompt);
64
+
65
+ const systemPrompt = 'You are a notification summary assistant. Respond with only the summary text, no preamble or explanation.';
66
+
67
+ const args = [
68
+ '--print',
69
+ '--model', 'haiku',
70
+ '--system-prompt', systemPrompt,
71
+ promptFile
72
+ ];
73
+
74
+ const claude = spawn('claude', args, {
75
+ cwd: workingDir,
76
+ stdio: ['ignore', 'pipe', 'pipe']
77
+ });
78
+
79
+ let stdout = '';
80
+ let stderr = '';
81
+
82
+ claude.stdout?.on('data', (data: Buffer) => {
83
+ stdout += data.toString();
84
+ });
85
+
86
+ claude.stderr?.on('data', (data: Buffer) => {
87
+ stderr += data.toString();
88
+ });
89
+
90
+ claude.on('close', (code: number | null) => {
91
+ try {
92
+ unlinkSync(promptFile);
93
+ } catch {
94
+ // Ignore cleanup errors
95
+ }
96
+
97
+ let summary: string;
98
+ if (code === 0 && stdout.trim()) {
99
+ summary = stdout.trim().slice(0, 150);
100
+ } else {
101
+ console.error('[WebSocketImproviseHandler] Claude error:', stderr || 'Unknown error');
102
+ summary = createFallbackSummary(userPrompt);
103
+ }
104
+
105
+ ctx.send(ws, {
106
+ type: 'notificationSummary',
107
+ tabId,
108
+ data: { summary }
109
+ });
110
+ });
111
+
112
+ claude.on('error', (err: Error) => {
113
+ console.error('[WebSocketImproviseHandler] Failed to spawn Claude:', err);
114
+ const summary = createFallbackSummary(userPrompt);
115
+ ctx.send(ws, {
116
+ type: 'notificationSummary',
117
+ tabId,
118
+ data: { summary }
119
+ });
120
+ });
121
+
122
+ // Timeout after 10 seconds
123
+ setTimeout(() => {
124
+ claude.kill();
125
+ const summary = createFallbackSummary(userPrompt);
126
+ ctx.send(ws, {
127
+ type: 'notificationSummary',
128
+ tabId,
129
+ data: { summary }
130
+ });
131
+ }, 10000);
132
+
133
+ } catch (error) {
134
+ console.error('[WebSocketImproviseHandler] Error generating summary:', error);
135
+ const summary = createFallbackSummary(userPrompt);
136
+ ctx.send(ws, {
137
+ type: 'notificationSummary',
138
+ tabId,
139
+ data: { summary }
140
+ });
141
+ }
142
+ }
143
+
144
+ function createFallbackSummary(userPrompt: string): string {
145
+ const truncated = userPrompt.slice(0, 60);
146
+ if (userPrompt.length > 60) {
147
+ return `Completed: "${truncated}..."`;
148
+ }
149
+ return `Completed: "${truncated}"`;
150
+ }
@@ -0,0 +1,150 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
5
+ import { getModel } from '../settings.js';
6
+ import type { HandlerContext } from './handler-context.js';
7
+ import { buildOutputHistory, setupSessionListeners } from './session-handlers.js';
8
+ import type { WebSocketMessage, WSContext } from './types.js';
9
+
10
+ export function handleGetActiveTabs(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
11
+ const registry = ctx.getRegistry(workingDir);
12
+ const allTabs = registry.getAllTabs();
13
+
14
+ const tabs: Record<string, unknown> = {};
15
+ for (const [tabId, regTab] of Object.entries(allTabs)) {
16
+ const session = ctx.sessions.get(regTab.sessionId);
17
+ if (session) {
18
+ tabs[tabId] = {
19
+ tabName: regTab.tabName,
20
+ createdAt: regTab.createdAt,
21
+ order: regTab.order,
22
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
23
+ sessionInfo: session.getSessionInfo(),
24
+ isExecuting: session.isExecuting,
25
+ outputHistory: buildOutputHistory(session),
26
+ executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
27
+ ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
28
+ };
29
+ } else {
30
+ tabs[tabId] = {
31
+ tabName: regTab.tabName,
32
+ createdAt: regTab.createdAt,
33
+ order: regTab.order,
34
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
35
+ sessionId: regTab.sessionId,
36
+ isExecuting: false,
37
+ outputHistory: [],
38
+ };
39
+ }
40
+ }
41
+
42
+ ctx.send(ws, { type: 'activeTabs', data: { tabs } });
43
+ }
44
+
45
+ export function handleSyncTabMeta(ctx: HandlerContext, _ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
46
+ const registry = ctx.getRegistry(workingDir);
47
+ if (msg.data?.tabName) {
48
+ registry.updateTabName(tabId, msg.data.tabName);
49
+ ctx.broadcastToAll({
50
+ type: 'tabRenamed',
51
+ data: { tabId, tabName: msg.data.tabName }
52
+ });
53
+ }
54
+ }
55
+
56
+ export function handleSyncPromptText(ctx: HandlerContext, _ws: WSContext, msg: WebSocketMessage, tabId: string): void {
57
+ if (typeof msg.data?.text !== 'string') return;
58
+ ctx.broadcastToAll({
59
+ type: 'promptTextSync',
60
+ tabId,
61
+ data: { tabId, text: msg.data.text }
62
+ });
63
+ }
64
+
65
+ export function handleRemoveTab(ctx: HandlerContext, _ws: WSContext, tabId: string, workingDir: string): void {
66
+ const registry = ctx.getRegistry(workingDir);
67
+ registry.unregisterTab(tabId);
68
+
69
+ ctx.broadcastToAll({
70
+ type: 'tabRemoved',
71
+ data: { tabId }
72
+ });
73
+ }
74
+
75
+ export function handleMarkTabViewed(ctx: HandlerContext, _ws: WSContext, tabId: string, workingDir: string): void {
76
+ const registry = ctx.getRegistry(workingDir);
77
+ registry.markTabViewed(tabId);
78
+
79
+ ctx.broadcastToAll({
80
+ type: 'tabViewed',
81
+ data: { tabId }
82
+ });
83
+ }
84
+
85
+ export async function handleCreateTab(ctx: HandlerContext, ws: WSContext, workingDir: string, tabName?: string, optimisticTabId?: string): Promise<void> {
86
+ const registry = ctx.getRegistry(workingDir);
87
+
88
+ const tabId = optimisticTabId || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
89
+
90
+ const existingSession = registry.getTabSession(tabId);
91
+ if (existingSession) {
92
+ const regTab = registry.getTab(tabId);
93
+ ctx.broadcastToAll({
94
+ type: 'tabCreated',
95
+ data: {
96
+ tabId,
97
+ tabName: regTab?.tabName || 'Chat',
98
+ createdAt: regTab?.createdAt,
99
+ order: regTab?.order,
100
+ sessionInfo: ctx.sessions.get(existingSession)?.getSessionInfo(),
101
+ }
102
+ });
103
+ return;
104
+ }
105
+
106
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
107
+ setupSessionListeners(ctx, session, ws, tabId);
108
+
109
+ const sessionId = session.getSessionInfo().sessionId;
110
+ ctx.sessions.set(sessionId, session);
111
+
112
+ const tabMap = ctx.connections.get(ws);
113
+ if (tabMap) tabMap.set(tabId, sessionId);
114
+
115
+ registry.registerTab(tabId, sessionId, tabName);
116
+ const registeredTab = registry.getTab(tabId);
117
+
118
+ ctx.broadcastToAll({
119
+ type: 'tabCreated',
120
+ data: {
121
+ tabId,
122
+ tabName: registeredTab?.tabName || 'Chat',
123
+ createdAt: registeredTab?.createdAt,
124
+ order: registeredTab?.order,
125
+ sessionInfo: session.getSessionInfo(),
126
+ }
127
+ });
128
+
129
+ ctx.send(ws, {
130
+ type: 'tabInitialized',
131
+ tabId,
132
+ data: session.getSessionInfo()
133
+ });
134
+ }
135
+
136
+ export function handleReorderTabs(ctx: HandlerContext, _ws: WSContext, workingDir: string, tabOrder?: string[]): void {
137
+ if (!Array.isArray(tabOrder)) return;
138
+ const registry = ctx.getRegistry(workingDir);
139
+ registry.reorderTabs(tabOrder);
140
+
141
+ const allTabs = registry.getAllTabs();
142
+ const orderMap = tabOrder
143
+ .filter((id) => allTabs[id])
144
+ .map((id) => ({ tabId: id, order: allTabs[id].order }));
145
+
146
+ ctx.broadcastToAll({
147
+ type: 'tabsReordered',
148
+ data: { tabOrder: orderMap }
149
+ });
150
+ }
@@ -0,0 +1,277 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import { AnalyticsEvents, trackEvent } from '../analytics.js';
5
+ import { getPTYManager } from '../terminal/pty-manager.js';
6
+ import type { HandlerContext } from './handler-context.js';
7
+ import type { WebSocketMessage, WSContext } from './types.js';
8
+
9
+ export function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
10
+ const termId = msg.terminalId || tabId;
11
+ switch (msg.type) {
12
+ case 'terminalInit':
13
+ handleTerminalInit(ctx, ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows, permission);
14
+ break;
15
+ case 'terminalReconnect':
16
+ handleTerminalReconnect(ctx, ws, termId);
17
+ break;
18
+ case 'terminalList':
19
+ handleTerminalList(ctx, ws);
20
+ break;
21
+ case 'terminalInput':
22
+ handleTerminalInput(ctx, ws, termId, msg.data?.input);
23
+ break;
24
+ case 'terminalResize':
25
+ handleTerminalResize(ctx, termId, msg.data?.cols, msg.data?.rows);
26
+ break;
27
+ case 'terminalClose':
28
+ handleTerminalClose(ctx, ws, termId);
29
+ break;
30
+ }
31
+ }
32
+
33
+ function handleTerminalInit(
34
+ ctx: HandlerContext,
35
+ ws: WSContext,
36
+ terminalId: string,
37
+ workingDir: string,
38
+ requestedShell?: string,
39
+ cols?: number,
40
+ rows?: number,
41
+ permission?: 'control' | 'view'
42
+ ): void {
43
+ const ptyManager = getPTYManager();
44
+
45
+ if (!ptyManager.isPtyAvailable()) {
46
+ ctx.send(ws, {
47
+ type: 'terminalError',
48
+ terminalId,
49
+ data: {
50
+ error: 'PTY_NOT_AVAILABLE',
51
+ instructions: ptyManager.getPtyInstallInstructions()
52
+ }
53
+ });
54
+ return;
55
+ }
56
+
57
+ addTerminalSubscriber(ctx, terminalId, ws);
58
+ setupTerminalBroadcastListeners(ctx, terminalId);
59
+
60
+ try {
61
+ const { shell, cwd, isReconnect } = ptyManager.create(
62
+ terminalId,
63
+ workingDir,
64
+ cols || 80,
65
+ rows || 24,
66
+ requestedShell,
67
+ { sandboxed: permission === 'control' || permission === 'view' }
68
+ );
69
+
70
+ if (!isReconnect) {
71
+ ctx.broadcastToOthers(ws, {
72
+ type: 'terminalCreated',
73
+ data: { terminalId, shell, cwd }
74
+ });
75
+ }
76
+
77
+ ctx.send(ws, {
78
+ type: 'terminalReady',
79
+ terminalId,
80
+ data: { shell, cwd, isReconnect }
81
+ });
82
+ trackEvent(AnalyticsEvents.TERMINAL_SESSION_CREATED, {
83
+ shell,
84
+ is_reconnect: isReconnect,
85
+ });
86
+ } catch (error: unknown) {
87
+ console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
88
+ ctx.send(ws, {
89
+ type: 'terminalError',
90
+ terminalId,
91
+ data: { error: (error instanceof Error ? error.message : String(error)) || 'Failed to create terminal' }
92
+ });
93
+ removeTerminalSubscriber(ctx, terminalId, ws);
94
+ }
95
+ }
96
+
97
+ function handleTerminalReconnect(ctx: HandlerContext, ws: WSContext, terminalId: string): void {
98
+ const ptyManager = getPTYManager();
99
+
100
+ const sessionInfo = ptyManager.getSessionInfo(terminalId);
101
+ if (!sessionInfo) {
102
+ ctx.send(ws, {
103
+ type: 'terminalError',
104
+ terminalId,
105
+ data: { error: 'Terminal session not found', sessionNotFound: true }
106
+ });
107
+ return;
108
+ }
109
+
110
+ addTerminalSubscriber(ctx, terminalId, ws);
111
+ setupTerminalBroadcastListeners(ctx, terminalId);
112
+
113
+ ctx.send(ws, {
114
+ type: 'terminalReady',
115
+ terminalId,
116
+ data: {
117
+ shell: sessionInfo.shell,
118
+ cwd: sessionInfo.cwd,
119
+ isReconnect: true
120
+ }
121
+ });
122
+
123
+ ptyManager.resize(terminalId, sessionInfo.cols, sessionInfo.rows);
124
+ }
125
+
126
+ function handleTerminalList(ctx: HandlerContext, ws: WSContext): void {
127
+ const ptyManager = getPTYManager();
128
+ const terminalIds = ptyManager.getActiveTerminals();
129
+
130
+ const terminals = terminalIds.map(id => {
131
+ const info = ptyManager.getSessionInfo(id);
132
+ return info ? { id, ...info } : null;
133
+ }).filter(Boolean);
134
+
135
+ ctx.send(ws, {
136
+ type: 'terminalList',
137
+ data: { terminals }
138
+ });
139
+ }
140
+
141
+ function handleTerminalInput(
142
+ ctx: HandlerContext,
143
+ ws: WSContext,
144
+ terminalId: string,
145
+ input?: string
146
+ ): void {
147
+ if (!input) return;
148
+
149
+ const ptyManager = getPTYManager();
150
+ const success = ptyManager.write(terminalId, input);
151
+
152
+ if (!success) {
153
+ ctx.send(ws, {
154
+ type: 'terminalError',
155
+ terminalId,
156
+ data: { error: 'Terminal not found or write failed' }
157
+ });
158
+ }
159
+ }
160
+
161
+ function handleTerminalResize(
162
+ _ctx: HandlerContext,
163
+ terminalId: string,
164
+ cols?: number,
165
+ rows?: number
166
+ ): void {
167
+ if (!cols || !rows) return;
168
+
169
+ const ptyManager = getPTYManager();
170
+ ptyManager.resize(terminalId, cols, rows);
171
+ }
172
+
173
+ function handleTerminalClose(ctx: HandlerContext, ws: WSContext, terminalId: string): void {
174
+ trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
175
+
176
+ const listenerCleanup = ctx.terminalListenerCleanups.get(terminalId);
177
+ if (listenerCleanup) {
178
+ listenerCleanup();
179
+ ctx.terminalListenerCleanups.delete(terminalId);
180
+ }
181
+
182
+ const ptyManager = getPTYManager();
183
+ ptyManager.close(terminalId);
184
+
185
+ ctx.terminalSubscribers.delete(terminalId);
186
+
187
+ ctx.broadcastToOthers(ws, {
188
+ type: 'terminalClosed',
189
+ data: { terminalId }
190
+ });
191
+ }
192
+
193
+ function addTerminalSubscriber(ctx: HandlerContext, terminalId: string, ws: WSContext): void {
194
+ let subs = ctx.terminalSubscribers.get(terminalId);
195
+ if (!subs) {
196
+ subs = new Set();
197
+ ctx.terminalSubscribers.set(terminalId, subs);
198
+ }
199
+ subs.add(ws);
200
+ }
201
+
202
+ function removeTerminalSubscriber(ctx: HandlerContext, terminalId: string, ws: WSContext): void {
203
+ const subs = ctx.terminalSubscribers.get(terminalId);
204
+ if (!subs) return;
205
+ subs.delete(ws);
206
+ if (subs.size > 0) return;
207
+ ctx.terminalSubscribers.delete(terminalId);
208
+ const cleanup = ctx.terminalListenerCleanups.get(terminalId);
209
+ if (cleanup) {
210
+ cleanup();
211
+ ctx.terminalListenerCleanups.delete(terminalId);
212
+ }
213
+ }
214
+
215
+ function setupTerminalBroadcastListeners(ctx: HandlerContext, terminalId: string): void {
216
+ if (ctx.terminalListenerCleanups.has(terminalId)) return;
217
+
218
+ const ptyManager = getPTYManager();
219
+
220
+ const onOutput = (tid: string, data: string) => {
221
+ if (tid === terminalId) {
222
+ const subs = ctx.terminalSubscribers.get(terminalId);
223
+ if (subs) {
224
+ for (const ws of subs) {
225
+ ctx.send(ws, { type: 'terminalOutput', terminalId, data: { output: data } });
226
+ }
227
+ }
228
+ }
229
+ };
230
+
231
+ const onExit = (tid: string, exitCode: number) => {
232
+ if (tid === terminalId) {
233
+ const subs = ctx.terminalSubscribers.get(terminalId);
234
+ if (subs) {
235
+ for (const ws of subs) {
236
+ ctx.send(ws, { type: 'terminalExit', terminalId, data: { exitCode } });
237
+ }
238
+ }
239
+ ptyManager.off('output', onOutput);
240
+ ptyManager.off('exit', onExit);
241
+ ptyManager.off('error', onError);
242
+ ctx.terminalListenerCleanups.delete(terminalId);
243
+ ctx.terminalSubscribers.delete(terminalId);
244
+ }
245
+ };
246
+
247
+ const onError = (tid: string, error: string) => {
248
+ if (tid === terminalId) {
249
+ const subs = ctx.terminalSubscribers.get(terminalId);
250
+ if (subs) {
251
+ for (const ws of subs) {
252
+ ctx.send(ws, { type: 'terminalError', terminalId, data: { error } });
253
+ }
254
+ }
255
+ }
256
+ };
257
+
258
+ ptyManager.on('output', onOutput);
259
+ ptyManager.on('exit', onExit);
260
+ ptyManager.on('error', onError);
261
+
262
+ ctx.terminalListenerCleanups.set(terminalId, () => {
263
+ ptyManager.off('output', onOutput);
264
+ ptyManager.off('exit', onExit);
265
+ ptyManager.off('error', onError);
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Clean up terminal subscribers for a disconnected WS context.
271
+ * Called from handler.ts handleClose().
272
+ */
273
+ export function cleanupTerminalSubscribers(ctx: HandlerContext, ws: WSContext): void {
274
+ for (const subs of ctx.terminalSubscribers.values()) {
275
+ subs.delete(ws);
276
+ }
277
+ }