mstro-app 0.1.54 → 0.1.57

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 (57) hide show
  1. package/bin/mstro.js +2 -1
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +151 -0
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  6. package/dist/server/cli/headless/runner.js +7 -1
  7. package/dist/server/cli/headless/runner.js.map +1 -1
  8. package/dist/server/cli/headless/stall-assessor.d.ts +30 -0
  9. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -0
  10. package/dist/server/cli/headless/stall-assessor.js +184 -0
  11. package/dist/server/cli/headless/stall-assessor.js.map +1 -0
  12. package/dist/server/cli/headless/types.d.ts +9 -1
  13. package/dist/server/cli/headless/types.d.ts.map +1 -1
  14. package/dist/server/cli/improvisation-session-manager.d.ts +21 -2
  15. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-session-manager.js +65 -5
  17. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  18. package/dist/server/index.js +4 -1
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  21. package/dist/server/mcp/bouncer-integration.js +32 -0
  22. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  23. package/dist/server/services/platform.d.ts.map +1 -1
  24. package/dist/server/services/platform.js +8 -5
  25. package/dist/server/services/platform.js.map +1 -1
  26. package/dist/server/services/settings.d.ts +25 -0
  27. package/dist/server/services/settings.d.ts.map +1 -0
  28. package/dist/server/services/settings.js +72 -0
  29. package/dist/server/services/settings.js.map +1 -0
  30. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
  31. package/dist/server/services/websocket/autocomplete.js +12 -15
  32. package/dist/server/services/websocket/autocomplete.js.map +1 -1
  33. package/dist/server/services/websocket/handler.d.ts +99 -2
  34. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  35. package/dist/server/services/websocket/handler.js +618 -157
  36. package/dist/server/services/websocket/handler.js.map +1 -1
  37. package/dist/server/services/websocket/session-registry.d.ts +38 -0
  38. package/dist/server/services/websocket/session-registry.d.ts.map +1 -0
  39. package/dist/server/services/websocket/session-registry.js +154 -0
  40. package/dist/server/services/websocket/session-registry.js.map +1 -0
  41. package/dist/server/services/websocket/types.d.ts +2 -2
  42. package/dist/server/services/websocket/types.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/server/cli/headless/RESEARCH.md +627 -0
  45. package/server/cli/headless/claude-invoker.ts +192 -1
  46. package/server/cli/headless/runner.ts +7 -1
  47. package/server/cli/headless/stall-assessor.ts +245 -0
  48. package/server/cli/headless/types.ts +9 -1
  49. package/server/cli/improvisation-session-manager.ts +73 -5
  50. package/server/index.ts +4 -1
  51. package/server/mcp/bouncer-integration.ts +32 -0
  52. package/server/services/platform.ts +8 -5
  53. package/server/services/settings.ts +89 -0
  54. package/server/services/websocket/autocomplete.ts +18 -14
  55. package/server/services/websocket/handler.ts +677 -170
  56. package/server/services/websocket/session-registry.ts +180 -0
  57. package/server/services/websocket/types.ts +31 -2
@@ -11,6 +11,7 @@
11
11
  import { EventEmitter } from 'node:events';
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
14
15
  import { HeadlessRunner } from './headless/index.js';
15
16
 
16
17
  export interface ImprovisationOptions {
@@ -20,6 +21,8 @@ export interface ImprovisationOptions {
20
21
  maxSessions: number;
21
22
  verbose: boolean;
22
23
  noColor: boolean;
24
+ /** Claude model for main execution (e.g., 'opus', 'sonnet'). 'default' = no --model flag. */
25
+ model?: string;
23
26
  }
24
27
 
25
28
  // File attachment for multimodal prompts (images)
@@ -82,12 +85,17 @@ export class ImprovisationSessionManager extends EventEmitter {
82
85
  private isResumedSession: boolean = false; // Track if this is a resumed historical session
83
86
  accumulatedKnowledge: string = '';
84
87
 
88
+ /** Whether a prompt is currently executing */
89
+ private _isExecuting: boolean = false;
90
+ /** Buffered events during current execution, for replay on reconnect */
91
+ private executionEventLog: Array<{ type: string; data: any; timestamp: number }> = [];
92
+
85
93
  /**
86
94
  * Resume from a historical session.
87
95
  * Creates a new session manager that continues the conversation from a previous session.
88
96
  * The first prompt will include context from the historical session.
89
97
  */
90
- static resumeFromHistory(workingDir: string, historicalSessionId: string): ImprovisationSessionManager {
98
+ static resumeFromHistory(workingDir: string, historicalSessionId: string, overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
91
99
  const improviseDir = join(workingDir, '.mstro', 'improvise');
92
100
 
93
101
  // Extract timestamp from session ID (format: improv-1234567890123 or just 1234567890123)
@@ -105,7 +113,8 @@ export class ImprovisationSessionManager extends EventEmitter {
105
113
  // This ensures we continue writing to the same history file
106
114
  const manager = new ImprovisationSessionManager({
107
115
  workingDir,
108
- sessionId: historyData.sessionId
116
+ sessionId: historyData.sessionId,
117
+ ...overrides,
109
118
  });
110
119
 
111
120
  // Load the historical data
@@ -138,7 +147,8 @@ export class ImprovisationSessionManager extends EventEmitter {
138
147
  tokenBudgetThreshold: options.tokenBudgetThreshold || 170000,
139
148
  maxSessions: options.maxSessions || 10,
140
149
  verbose: options.verbose || false,
141
- noColor: options.noColor || false
150
+ noColor: options.noColor || false,
151
+ model: options.model,
142
152
  };
143
153
 
144
154
  this.sessionId = this.options.sessionId;
@@ -218,11 +228,31 @@ export class ImprovisationSessionManager extends EventEmitter {
218
228
  async executePrompt(userPrompt: string, attachments?: FileAttachment[]): Promise<MovementRecord> {
219
229
  const _execStart = Date.now();
220
230
 
231
+ // Start execution event log for reconnect replay
232
+ this._isExecuting = true;
233
+ this.executionEventLog = [];
234
+
221
235
  this.emit('onMovementStart', this.history.movements.length + 1, userPrompt);
236
+ trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
237
+ prompt_length: userPrompt.length,
238
+ has_attachments: !!(attachments && attachments.length > 0),
239
+ attachment_count: attachments?.length || 0,
240
+ image_attachment_count: attachments?.filter(a => a.isImage).length || 0,
241
+ sequence_number: this.history.movements.length + 1,
242
+ is_resumed_session: this.isResumedSession,
243
+ model: this.options.model || 'default',
244
+ });
222
245
 
223
246
  try {
224
247
  const sequenceNumber = this.history.movements.length + 1;
225
248
 
249
+ // Log the movement start event
250
+ this.executionEventLog.push({
251
+ type: 'movementStart',
252
+ data: { sequenceNumber, prompt: userPrompt, timestamp: Date.now() },
253
+ timestamp: Date.now(),
254
+ });
255
+
226
256
  // DEBUG: Removed "Executing prompt..." message - it serves no purpose now that system responds fast
227
257
  // this.queueOutput(`\n🎵 Executing prompt...\n`);
228
258
  // this.flushOutputQueue();
@@ -245,19 +275,23 @@ export class ImprovisationSessionManager extends EventEmitter {
245
275
  maxSessions: this.options.maxSessions,
246
276
  verbose: this.options.verbose,
247
277
  noColor: this.options.noColor,
278
+ model: this.options.model,
248
279
  improvisationMode: true,
249
280
  movementNumber: sequenceNumber,
250
281
  continueSession: !this.isFirstPrompt, // Used as fallback only if claudeSessionId is missing
251
282
  claudeSessionId: this.claudeSessionId, // Resume specific session for tab isolation
252
283
  outputCallback: (text: string) => {
284
+ this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
253
285
  this.queueOutput(text);
254
286
  this.flushOutputQueue();
255
287
  },
256
288
  thinkingCallback: (text: string) => {
289
+ this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
257
290
  this.emit('onThinking', text);
258
291
  this.flushOutputQueue();
259
292
  },
260
293
  toolUseCallback: (event) => {
294
+ this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
261
295
  this.emit('onToolUse', event);
262
296
  this.flushOutputQueue();
263
297
  },
@@ -334,14 +368,32 @@ export class ImprovisationSessionManager extends EventEmitter {
334
368
  // this.queueOutput(`\n✓ Complete (tokens: ${result.totalTokens.toLocaleString()})\n`);
335
369
  // this.flushOutputQueue();
336
370
 
371
+ this._isExecuting = false;
372
+ this.executionEventLog = [];
373
+
337
374
  this.emit('onMovementComplete', movement);
375
+ trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
376
+ tokens_used: movement.tokensUsed,
377
+ duration_ms: Date.now() - _execStart,
378
+ sequence_number: sequenceNumber,
379
+ tool_count: result.toolUseHistory?.length || 0,
380
+ model: this.options.model || 'default',
381
+ });
338
382
  this.emit('onSessionUpdate', this.getHistory());
339
383
 
340
384
  return movement;
341
385
 
342
386
  } catch (error: any) {
387
+ this._isExecuting = false;
388
+ this.executionEventLog = [];
343
389
  this.currentRunner = null;
344
390
  this.emit('onMovementError', error);
391
+ trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
392
+ error_message: error.message?.slice(0, 200),
393
+ sequence_number: this.history.movements.length + 1,
394
+ duration_ms: Date.now() - _execStart,
395
+ model: this.options.model || 'default',
396
+ });
345
397
  this.queueOutput(`\n❌ Error: ${error.message}\n`);
346
398
  this.flushOutputQueue();
347
399
  throw error;
@@ -508,12 +560,27 @@ export class ImprovisationSessionManager extends EventEmitter {
508
560
  };
509
561
  }
510
562
 
563
+ /**
564
+ * Whether a prompt is currently executing
565
+ */
566
+ get isExecuting(): boolean {
567
+ return this._isExecuting;
568
+ }
569
+
570
+ /**
571
+ * Get buffered execution events for replay on reconnect.
572
+ * Only meaningful while isExecuting is true.
573
+ */
574
+ getExecutionEventLog(): Array<{ type: string; data: any; timestamp: number }> {
575
+ return this.executionEventLog;
576
+ }
577
+
511
578
  /**
512
579
  * Start a new session with fresh context
513
580
  * Creates a completely new session manager with isFirstPrompt=true and no claudeSessionId,
514
581
  * ensuring the next prompt starts a fresh Claude conversation (proper tab isolation)
515
582
  */
516
- startNewSession(): ImprovisationSessionManager {
583
+ startNewSession(overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
517
584
  // Save current session
518
585
  this.saveHistory();
519
586
 
@@ -523,7 +590,8 @@ export class ImprovisationSessionManager extends EventEmitter {
523
590
  // This means the first prompt will start a completely fresh Claude conversation
524
591
  const newSession = new ImprovisationSessionManager({
525
592
  ...this.options,
526
- sessionId: `improv-${Date.now()}`
593
+ sessionId: `improv-${Date.now()}`,
594
+ ...overrides,
527
595
  });
528
596
 
529
597
  return newSession;
package/server/index.ts CHANGED
@@ -348,7 +348,10 @@ async function startServer() {
348
348
  console.log(`Framework: Hono`)
349
349
 
350
350
  // Track server started event
351
- trackEvent(AnalyticsEvents.SERVER_STARTED, { port: PORT })
351
+ trackEvent(AnalyticsEvents.SERVER_STARTED, {
352
+ port: PORT,
353
+ working_dir_basename: basename(WORKING_DIR),
354
+ })
352
355
 
353
356
  // Create a virtual WebSocket context for platform relay
354
357
  // This allows messages from the web (via platform) to use the same wsHandler
@@ -33,6 +33,7 @@
33
33
  */
34
34
 
35
35
  import { spawn } from 'node:child_process';
36
+ import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
36
37
  import { captureException } from '../services/sentry.js';
37
38
  import {
38
39
  CRITICAL_THREATS,
@@ -275,6 +276,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
275
276
  decision.reasoning,
276
277
  { context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-safe', latencyMs }
277
278
  );
279
+ trackEvent(AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
280
+ layer: 'pattern-safe',
281
+ operation_length: operation.length,
282
+ threat_level: 'low',
283
+ confidence: 95,
284
+ latency_ms: latencyMs,
285
+ });
278
286
 
279
287
  return decision;
280
288
  }
@@ -302,6 +310,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
302
310
  decision.reasoning,
303
311
  { context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-critical', latencyMs }
304
312
  );
313
+ trackEvent(AnalyticsEvents.BOUNCER_TOOL_DENIED, {
314
+ layer: 'pattern-critical',
315
+ operation_length: operation.length,
316
+ threat_level: 'critical',
317
+ confidence: 99,
318
+ latency_ms: latencyMs,
319
+ });
305
320
 
306
321
  return decision;
307
322
  }
@@ -330,6 +345,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
330
345
  decision.reasoning,
331
346
  { context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-default', latencyMs }
332
347
  );
348
+ trackEvent(AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
349
+ layer: 'pattern-default',
350
+ operation_length: operation.length,
351
+ threat_level: 'low',
352
+ confidence: 80,
353
+ latency_ms: latencyMs,
354
+ });
333
355
 
334
356
  return decision;
335
357
  }
@@ -360,6 +382,9 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
360
382
  }
361
383
 
362
384
  console.error('[Bouncer] 🤖 Invoking Haiku for AI analysis...');
385
+ trackEvent(AnalyticsEvents.BOUNCER_HAIKU_REVIEW, {
386
+ operation_length: operation.length,
387
+ });
363
388
 
364
389
  // Get Claude command and working directory from context or use defaults
365
390
  const claudeCommand = process.env.CLAUDE_COMMAND || 'claude';
@@ -378,6 +403,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
378
403
  decision.reasoning,
379
404
  { context: request.context, threatLevel: decision.threatLevel, layer: 'haiku-ai', latencyMs }
380
405
  );
406
+ trackEvent(decision.decision === 'deny' ? AnalyticsEvents.BOUNCER_TOOL_DENIED : AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
407
+ layer: 'haiku-ai',
408
+ operation_length: operation.length,
409
+ threat_level: decision.threatLevel,
410
+ confidence: decision.confidence,
411
+ latency_ms: latencyMs,
412
+ });
381
413
 
382
414
  return decision;
383
415
 
@@ -17,6 +17,7 @@
17
17
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
18
18
  import { arch, homedir, hostname, type } from 'node:os'
19
19
  import { basename, join } from 'node:path'
20
+ import { AnalyticsEvents, trackEvent } from './analytics.js'
20
21
  import { getClientId } from './client-id.js'
21
22
  import { captureException } from './sentry.js'
22
23
  import { isTmuxAvailable } from './terminal/tmux-manager.js'
@@ -93,15 +94,13 @@ export function getMachineIdentifier(): string {
93
94
  return `${machineHostname} @ node-${nodeVersion} ${osType} (${cpuArch})`
94
95
  }
95
96
 
96
- // Get WebSocket class - use global if available (Bun, Node 21+), otherwise import from undici (Node 18-20)
97
+ // Get WebSocket class - use global if available (Bun, Node 21+), otherwise use ws (Node 18-20)
97
98
  let WebSocketImpl: typeof WebSocket
98
99
  if (typeof WebSocket !== 'undefined') {
99
100
  WebSocketImpl = WebSocket
100
101
  } else {
101
- // Node 18-20: use undici's WebSocket (bundled with Node.js but not typed)
102
- // @ts-expect-error undici is bundled with Node.js but lacks type declarations
103
- const { WebSocket: UndiciWS } = await import('undici')
104
- WebSocketImpl = UndiciWS as unknown as typeof WebSocket
102
+ const { default: WS } = await import('ws')
103
+ WebSocketImpl = WS as unknown as typeof WebSocket
105
104
  }
106
105
 
107
106
  const DEFAULT_PLATFORM_URL = process.env.PLATFORM_URL || 'https://api.mstro.app'
@@ -295,6 +294,7 @@ export class PlatformConnection {
295
294
  // Start periodic refresh checks
296
295
  this.startTokenRefreshCheck()
297
296
  this.reconnectAttempts = 0
297
+ trackEvent(AnalyticsEvents.PLATFORM_CONNECTED)
298
298
  }
299
299
 
300
300
  this.ws.onmessage = (event) => {
@@ -334,6 +334,7 @@ export class PlatformConnection {
334
334
 
335
335
  console.log('Disconnected from platform, reconnecting...')
336
336
  this.callbacks.onDisconnected?.()
337
+ trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
337
338
  this.scheduleReconnect()
338
339
  }
339
340
  }
@@ -357,11 +358,13 @@ export class PlatformConnection {
357
358
  case 'web_connected':
358
359
  console.log('🔗 Web client connected')
359
360
  this.callbacks.onWebConnected?.()
361
+ trackEvent(AnalyticsEvents.WEB_CLIENT_CONNECTED)
360
362
  break
361
363
 
362
364
  case 'web_disconnected':
363
365
  console.log('🔗 Web client disconnected')
364
366
  this.callbacks.onWebDisconnected?.()
367
+ trackEvent(AnalyticsEvents.WEB_CLIENT_DISCONNECTED)
365
368
  break
366
369
 
367
370
  case 'pong':
@@ -0,0 +1,89 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Settings Service
6
+ *
7
+ * Manages persistent machine-wide settings stored in ~/.mstro/settings.json
8
+ *
9
+ * Structure:
10
+ * {
11
+ * "model": "opus"
12
+ * }
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+
19
+ const MSTRO_DIR = join(homedir(), '.mstro')
20
+ const SETTINGS_FILE = join(MSTRO_DIR, 'settings.json')
21
+
22
+ export interface MstroSettings {
23
+ /**
24
+ * Claude model to use for main execution.
25
+ * - 'default' means don't pass --model (let Claude Code decide)
26
+ * - Any other string is passed as --model <value>
27
+ */
28
+ model: string
29
+ }
30
+
31
+ const DEFAULT_SETTINGS: MstroSettings = {
32
+ model: 'opus'
33
+ }
34
+
35
+ /**
36
+ * Ensure the ~/.mstro directory exists
37
+ */
38
+ function ensureMstroDir(): void {
39
+ if (!existsSync(MSTRO_DIR)) {
40
+ mkdirSync(MSTRO_DIR, { recursive: true, mode: 0o700 })
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Get current settings, merged with defaults for any missing fields
46
+ */
47
+ export function getSettings(): MstroSettings {
48
+ if (!existsSync(SETTINGS_FILE)) {
49
+ return { ...DEFAULT_SETTINGS }
50
+ }
51
+
52
+ try {
53
+ const content = readFileSync(SETTINGS_FILE, 'utf-8')
54
+ const stored = JSON.parse(content)
55
+ return {
56
+ ...DEFAULT_SETTINGS,
57
+ ...stored,
58
+ }
59
+ } catch (err) {
60
+ console.warn('Failed to read settings file, using defaults:', err)
61
+ return { ...DEFAULT_SETTINGS }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Save full settings to disk
67
+ */
68
+ export function saveSettings(settings: MstroSettings): void {
69
+ ensureMstroDir()
70
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), {
71
+ mode: 0o600
72
+ })
73
+ }
74
+
75
+ /**
76
+ * Get the current model setting
77
+ */
78
+ export function getModel(): string {
79
+ return getSettings().model
80
+ }
81
+
82
+ /**
83
+ * Update just the model setting
84
+ */
85
+ export function setModel(model: string): void {
86
+ const settings = getSettings()
87
+ settings.model = model
88
+ saveSettings(settings)
89
+ }
@@ -46,22 +46,26 @@ function compareAutocompleteResults(a: ScoredMatch, b: ScoredMatch): number {
46
46
  function extractFuseMatchIndices(
47
47
  result: FuseResult<FileMetadata>
48
48
  ): Array<[number, number]> {
49
- const matchedIndices: Array<[number, number]> = [];
50
- if (!result.matches) return matchedIndices;
51
-
52
- for (const match of result.matches) {
53
- if (!match.indices) continue;
54
- for (const [start, end] of match.indices) {
55
- if (match.key === 'fileName') {
56
- const filenameStart = result.item.relativePath.lastIndexOf('/') + 1;
57
- matchedIndices.push([filenameStart + start, filenameStart + end + 1]);
58
- } else {
59
- matchedIndices.push([start, end + 1]);
60
- }
61
- }
49
+ if (!result.matches) return [];
50
+
51
+ // Prefer fileName matches (95% weight) over relativePath to avoid duplicates.
52
+ // Using both keys produces overlapping index ranges that garble the display.
53
+ const fileNameMatch = result.matches.find(m => m.key === 'fileName');
54
+ if (fileNameMatch?.indices) {
55
+ const filenameStart = result.item.relativePath.lastIndexOf('/') + 1;
56
+ return fileNameMatch.indices.map(([start, end]) =>
57
+ [filenameStart + start, filenameStart + end + 1] as [number, number]
58
+ );
59
+ }
60
+
61
+ const pathMatch = result.matches.find(m => m.key === 'relativePath');
62
+ if (pathMatch?.indices) {
63
+ return pathMatch.indices.map(([start, end]) =>
64
+ [start, end + 1] as [number, number]
65
+ );
62
66
  }
63
67
 
64
- return matchedIndices;
68
+ return [];
65
69
  }
66
70
 
67
71
  function scoreFileMatch(