mstro-app 0.4.32 → 0.4.33

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 (48) hide show
  1. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  2. package/dist/server/cli/improvisation-retry.js +18 -1
  3. package/dist/server/cli/improvisation-retry.js.map +1 -1
  4. package/dist/server/cli/improvisation-session-manager.d.ts +5 -0
  5. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  6. package/dist/server/cli/improvisation-session-manager.js +41 -1
  7. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  8. package/dist/server/services/files.d.ts.map +1 -1
  9. package/dist/server/services/files.js +6 -2
  10. package/dist/server/services/files.js.map +1 -1
  11. package/dist/server/services/plan/agent-loader.d.ts.map +1 -1
  12. package/dist/server/services/plan/agent-loader.js +1 -17
  13. package/dist/server/services/plan/agent-loader.js.map +1 -1
  14. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  15. package/dist/server/services/websocket/file-explorer-handlers.js +23 -2
  16. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  17. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  18. package/dist/server/services/websocket/git-handlers.js +15 -0
  19. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  20. package/dist/server/services/websocket/handler.d.ts +2 -0
  21. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  22. package/dist/server/services/websocket/handler.js +36 -18
  23. package/dist/server/services/websocket/handler.js.map +1 -1
  24. package/dist/server/services/websocket/session-history.d.ts.map +1 -1
  25. package/dist/server/services/websocket/session-history.js +10 -8
  26. package/dist/server/services/websocket/session-history.js.map +1 -1
  27. package/dist/server/services/websocket/skill-handlers.d.ts +4 -0
  28. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -0
  29. package/dist/server/services/websocket/skill-handlers.js +93 -0
  30. package/dist/server/services/websocket/skill-handlers.js.map +1 -0
  31. package/dist/server/services/websocket/types.d.ts +8 -2
  32. package/dist/server/services/websocket/types.d.ts.map +1 -1
  33. package/dist/server/utils/paths.d.ts +4 -0
  34. package/dist/server/utils/paths.d.ts.map +1 -1
  35. package/dist/server/utils/paths.js +18 -1
  36. package/dist/server/utils/paths.js.map +1 -1
  37. package/package.json +1 -1
  38. package/server/cli/improvisation-retry.ts +19 -1
  39. package/server/cli/improvisation-session-manager.ts +44 -1
  40. package/server/services/files.ts +7 -2
  41. package/server/services/plan/agent-loader.ts +1 -16
  42. package/server/services/websocket/file-explorer-handlers.ts +23 -2
  43. package/server/services/websocket/git-handlers.ts +17 -0
  44. package/server/services/websocket/handler.ts +35 -16
  45. package/server/services/websocket/session-history.ts +10 -8
  46. package/server/services/websocket/skill-handlers.ts +90 -0
  47. package/server/services/websocket/types.ts +13 -2
  48. package/server/utils/paths.ts +17 -1
@@ -6,7 +6,8 @@
6
6
  * Provides consistent path resolution for installed npm package.
7
7
  * Works correctly whether running from source or installed globally.
8
8
  */
9
- import { dirname, resolve } from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  // ES module equivalent of __dirname for this file
12
13
  const __filename = fileURLToPath(import.meta.url);
@@ -23,4 +24,20 @@ export const MSTRO_ROOT = resolve(__dirname, '../..');
23
24
  * Path to the MCP bouncer server script
24
25
  */
25
26
  export const MCP_SERVER_PATH = resolve(MSTRO_ROOT, 'server/mcp/server.ts');
27
+ /**
28
+ * Walk up from startDir looking for `.claude/skills/`. Returns the path if found, null otherwise.
29
+ */
30
+ export function findSkillsDir(startDir) {
31
+ let dir = startDir;
32
+ for (let i = 0; i < 10; i++) {
33
+ const candidate = join(dir, '.claude', 'skills');
34
+ if (existsSync(candidate))
35
+ return candidate;
36
+ const parent = dirname(dir);
37
+ if (parent === dir)
38
+ break;
39
+ dir = parent;
40
+ }
41
+ return null;
42
+ }
26
43
  //# sourceMappingURL=paths.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../../server/utils/paths.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,gEAAgE;AAEhE;;;;;GAKG;AAEH,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,kDAAkD;AAClD,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC,UAAU,EAAE,sBAAsB,CAAC,CAAC"}
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../../server/utils/paths.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,gEAAgE;AAEhE;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,kDAAkD;AAClD,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC,UAAU,EAAE,sBAAsB,CAAC,CAAC;AAE3E;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,IAAI,GAAG,GAAG,QAAQ,CAAC;IACnB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC;QACjD,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;QAC5C,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,MAAM,KAAK,GAAG;YAAE,MAAM;QAC1B,GAAG,GAAG,MAAM,CAAC;IACf,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mstro-app",
3
- "version": "0.4.32",
3
+ "version": "0.4.33",
4
4
  "description": "Run Claude Code from any browser - streams live sessions from your machine to mstro.app",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -455,6 +455,23 @@ function isPrematureCompletionCandidate(
455
455
  return result.stopReason === 'max_tokens' || result.stopReason === 'end_turn';
456
456
  }
457
457
 
458
+ /**
459
+ * Fast heuristic: detect response abandonment without a Haiku call.
460
+ * When thinking is significantly longer than the response and the response
461
+ * contains no tool calls, Claude likely planned work it never executed.
462
+ * This pattern occurs after context compaction or heavy parallel tool results.
463
+ */
464
+ function isResponseAbandoned(result: HeadlessRunResult): boolean {
465
+ const thinkingLen = result.thinkingOutput?.length ?? 0;
466
+ const responseLen = result.assistantResponse?.length ?? 0;
467
+ const toolCallsInResponse = result.toolUseHistory?.filter(t => t.result !== undefined).length ?? 0;
468
+
469
+ if (thinkingLen < 500 || responseLen > 1000) return false;
470
+ if (toolCallsInResponse > 0 && responseLen > 200) return false;
471
+
472
+ return thinkingLen >= responseLen * 3;
473
+ }
474
+
458
475
  /** Use Haiku to assess whether an end_turn response is genuinely complete */
459
476
  async function assessEndTurnCompletion(result: HeadlessRunResult, verbose: boolean): Promise<boolean> {
460
477
  if (!result.assistantResponse) return false;
@@ -531,7 +548,8 @@ export async function shouldRetryPrematureCompletion(
531
548
 
532
549
  const stopReason = result.stopReason!;
533
550
  const isMaxTokens = stopReason === 'max_tokens';
534
- const isIncomplete = isMaxTokens || await assessEndTurnCompletion(result, session.options.verbose);
551
+ const abandoned = isResponseAbandoned(result);
552
+ const isIncomplete = isMaxTokens || abandoned || await assessEndTurnCompletion(result, session.options.verbose);
535
553
 
536
554
  if (!isIncomplete) return false;
537
555
 
@@ -115,7 +115,7 @@ export class ImprovisationSessionManager extends EventEmitter {
115
115
  // ========== Output Queue ==========
116
116
 
117
117
  private startQueueProcessor(): void {
118
- this.queueTimer = setInterval(() => { this.flushOutputQueue(); }, 10);
118
+ this.queueTimer = setInterval(() => { this.flushOutputQueue(); }, 50);
119
119
  }
120
120
 
121
121
  private queueOutput(text: string): void {
@@ -136,6 +136,10 @@ export class ImprovisationSessionManager extends EventEmitter {
136
136
  this._isExecuting = true;
137
137
  this._cancelled = false;
138
138
  this._cancelCompleteEmitted = false;
139
+ if (userPrompt !== 'continue') {
140
+ this._autoContinueCount = 0;
141
+ this._autoContinuePending = false;
142
+ }
139
143
  this._executionStartTimestamp = _execStart;
140
144
  this.executionEventLog = [];
141
145
 
@@ -212,6 +216,11 @@ export class ImprovisationSessionManager extends EventEmitter {
212
216
  this.executionEventLog = [];
213
217
 
214
218
  this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
219
+
220
+ if (this.shouldAutoContinue(result, userPrompt)) {
221
+ this.scheduleAutoContinue();
222
+ }
223
+
215
224
  return movement;
216
225
 
217
226
  } catch (error: unknown) {
@@ -474,6 +483,40 @@ export class ImprovisationSessionManager extends EventEmitter {
474
483
  this.emit('onSessionUpdate', this.getHistory());
475
484
  }
476
485
 
486
+ // ========== Auto-Continue ==========
487
+
488
+ private _autoContinueCount = 0;
489
+ private _autoContinuePending = false;
490
+ private static readonly MAX_AUTO_CONTINUES = 1;
491
+
492
+ private shouldAutoContinue(result: HeadlessRunResult, _userPrompt: string): boolean {
493
+ if (this._autoContinueCount >= ImprovisationSessionManager.MAX_AUTO_CONTINUES) return false;
494
+ if (this._cancelled) return false;
495
+ if (!result.completed || result.signalName) return false;
496
+ if (result.stopReason !== 'end_turn') return false;
497
+
498
+ const thinkingLen = result.thinkingOutput?.length ?? 0;
499
+ const responseLen = result.assistantResponse?.length ?? 0;
500
+
501
+ if (thinkingLen < 500 || responseLen > 1000) return false;
502
+ return thinkingLen >= responseLen * 3;
503
+ }
504
+
505
+ private scheduleAutoContinue(): void {
506
+ this._autoContinueCount++;
507
+ this._autoContinuePending = true;
508
+ this.queueOutput('\n⟳ Response appears incomplete — auto-continuing…\n');
509
+ this.flushOutputQueue();
510
+
511
+ setImmediate(() => {
512
+ if (this._cancelled || this._isExecuting || !this._autoContinuePending) return;
513
+ this._autoContinuePending = false;
514
+ this.executePrompt('continue').catch((err) => {
515
+ herror('Auto-continue failed:', err);
516
+ });
517
+ });
518
+ }
519
+
477
520
  // ========== History I/O ==========
478
521
 
479
522
  private loadHistory(): SessionHistory {
@@ -115,8 +115,13 @@ export class FileService {
115
115
  isDirectory: entry.isDirectory()
116
116
  })
117
117
 
118
- // Recursively search directories (with depth limit)
119
- if (entry.isDirectory() && results.length < 1000) {
118
+ if (results.length >= 1000) {
119
+ console.warn('[FilesService] Directory scan hit 1000-item limit — results may be incomplete');
120
+ return results;
121
+ }
122
+
123
+ // Recursively search directories
124
+ if (entry.isDirectory()) {
120
125
  this.scanDirectory(fullPath, baseDir, results)
121
126
  }
122
127
  }
@@ -16,6 +16,7 @@
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { findSkillsDir } from '../../utils/paths.js';
19
20
 
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
22
  const SYSTEM_AGENTS_DIR = join(__dirname, 'agents');
@@ -46,22 +47,6 @@ function tryLoadFile(filePath: string, variables: Record<string, string>): strin
46
47
  }
47
48
  }
48
49
 
49
- /**
50
- * Resolve the project root by walking up from a directory looking for `.claude/skills/`.
51
- * Returns the `.claude/skills/` path if found, or null.
52
- */
53
- function findSkillsDir(startDir: string): string | null {
54
- let dir = startDir;
55
- for (let i = 0; i < 10; i++) {
56
- const candidate = join(dir, '.claude', 'skills');
57
- if (existsSync(candidate)) return candidate;
58
- const parent = dirname(dir);
59
- if (parent === dir) break;
60
- dir = parent;
61
- }
62
- return null;
63
- }
64
-
65
50
  /**
66
51
  * Load an agent prompt by name with layered resolution.
67
52
  *
@@ -111,9 +111,30 @@ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, ms
111
111
  handleRenameFile(ctx, ws, msg, tabId, workingDir);
112
112
  },
113
113
  notifyFileOpened: () => handleNotifyFileOpened(ctx, ws, msg, workingDir),
114
- searchFileContents: () => handleSearchFileContents(ctx, ws, msg, tabId, workingDir),
114
+ searchFileContents: () => {
115
+ if (isSandboxed && msg.data?.query) {
116
+ const searchPath = msg.data.path || msg.data.dirPath;
117
+ if (searchPath) {
118
+ const validation = validatePathWithinWorkingDir(searchPath, workingDir);
119
+ if (!validation.valid) {
120
+ ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: 'Sandboxed: search path outside project directory' } });
121
+ return;
122
+ }
123
+ }
124
+ }
125
+ handleSearchFileContents(ctx, ws, msg, tabId, workingDir);
126
+ },
115
127
  cancelSearch: () => handleCancelSearch(ctx, tabId),
116
- findDefinition: () => handleFindDefinition(ctx, ws, msg, tabId, workingDir),
128
+ findDefinition: () => {
129
+ if (isSandboxed && msg.data?.filePath) {
130
+ const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
131
+ if (!validation.valid) {
132
+ ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol: msg.data.symbol || '' } });
133
+ return;
134
+ }
135
+ }
136
+ handleFindDefinition(ctx, ws, msg, tabId, workingDir);
137
+ },
117
138
  };
118
139
  const handler = handlers[msg.type];
119
140
  if (!handler) return;
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
+ import { resolve } from 'node:path';
4
5
  import { loadSkillPrompt } from '../plan/agent-loader.js';
5
6
  import { handleGitCheckout, handleGitCreateBranch, handleGitDeleteBranch, handleGitListBranches } from './git-branch-handlers.js';
6
7
  import { handleGitCommitDiff, handleGitDiff, handleGitShowCommit } from './git-diff-handlers.js';
@@ -120,6 +121,16 @@ async function handleGitStage(ctx: HandlerContext, ws: WSContext, msg: WebSocket
120
121
  return;
121
122
  }
122
123
 
124
+ if (!stageAll && paths) {
125
+ const resolvedRoot = resolve(workingDir);
126
+ for (const p of paths) {
127
+ if (!resolve(workingDir, p).startsWith(resolvedRoot)) {
128
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: `Path traversal not allowed: ${p}` } });
129
+ return;
130
+ }
131
+ }
132
+ }
133
+
123
134
  try {
124
135
  const args = stageAll ? ['add', '-A'] : ['add', '--', ...paths!];
125
136
  const result = await executeGitCommand(args, workingDir);
@@ -154,12 +165,18 @@ async function handleGitUnstage(ctx: HandlerContext, ws: WSContext, msg: WebSock
154
165
  }
155
166
  }
156
167
 
168
+ const MAX_COMMIT_MESSAGE_LENGTH = 10_000;
169
+
157
170
  async function handleGitCommit(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
158
171
  const message = msg.data?.message as string | undefined;
159
172
  if (!message) {
160
173
  ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit message is required' } });
161
174
  return;
162
175
  }
176
+ if (message.length > MAX_COMMIT_MESSAGE_LENGTH) {
177
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: `Commit message too long (${message.length} chars, max ${MAX_COMMIT_MESSAGE_LENGTH})` } });
178
+ return;
179
+ }
163
180
 
164
181
  try {
165
182
  const result = await executeGitCommand(['commit', '-m', message], workingDir);
@@ -25,6 +25,7 @@ import { handleQualityMessage } from './quality-handlers.js';
25
25
  import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistoricalSession } from './session-handlers.js';
26
26
  import { SessionRegistry } from './session-registry.js';
27
27
  import { generateNotificationSummary, handleGetSettings, handleUpdateSettings } from './settings-handlers.js';
28
+ import { handleListSkills } from './skill-handlers.js';
28
29
  import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncPromptText, handleSyncTabMeta } from './tab-handlers.js';
29
30
  import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
30
31
  import type { FrecencyData, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
@@ -53,6 +54,9 @@ export class WebSocketImproviseHandler implements HandlerContext {
53
54
  }
54
55
 
55
56
  getRegistry(workingDir: string): SessionRegistry {
57
+ if (!this.sessionRegistry && workingDir) {
58
+ this.sessionRegistry = new SessionRegistry(workingDir);
59
+ }
56
60
  if (!this.sessionRegistry) {
57
61
  this.sessionRegistry = new SessionRegistry(workingDir);
58
62
  }
@@ -87,9 +91,16 @@ export class WebSocketImproviseHandler implements HandlerContext {
87
91
  }
88
92
  }
89
93
 
94
+ private frecencySaveTimer: ReturnType<typeof setTimeout> | null = null;
95
+
90
96
  recordFileSelection(filePath: string): void {
91
97
  this.autocompleteService.recordFileSelection(filePath);
92
- this.saveFrecencyData();
98
+ if (!this.frecencySaveTimer) {
99
+ this.frecencySaveTimer = setTimeout(() => {
100
+ this.frecencySaveTimer = null;
101
+ this.saveFrecencyData();
102
+ }, 2000);
103
+ }
93
104
  }
94
105
 
95
106
  handleConnection(ws: WSContext, _workingDir: string): void {
@@ -175,6 +186,8 @@ export class WebSocketImproviseHandler implements HandlerContext {
175
186
  return handleGetSettings(this, ws);
176
187
  case 'updateSettings':
177
188
  return handleUpdateSettings(this, ws, msg);
189
+ case 'listSkills':
190
+ return handleListSkills(this, ws, workingDir);
178
191
  }
179
192
 
180
193
  // Dispatch table lookup for domain handlers
@@ -222,28 +235,14 @@ export class WebSocketImproviseHandler implements HandlerContext {
222
235
  }
223
236
 
224
237
  handleClose(ws: WSContext): void {
225
- // Destroy sessions owned by this connection to free interval timers
226
238
  const tabMap = this.connections.get(ws);
227
239
  if (tabMap) {
228
- const sessionIds = new Set(tabMap.values());
229
- for (const sessionId of sessionIds) {
230
- const session = this.sessions.get(sessionId);
231
- if (session) {
232
- session.destroy();
233
- this.sessions.delete(sessionId);
234
- }
235
- }
240
+ this.cleanupConnectionResources(tabMap);
236
241
  }
237
242
  this.connections.delete(ws);
238
243
  this.allConnections.delete(ws);
239
244
  cleanupTerminalSubscribers(this, ws);
240
245
 
241
- // Kill any active search processes to prevent resource leaks
242
- for (const [key, process] of this.activeSearches) {
243
- try { process.kill(); } catch { /* ignore */ }
244
- this.activeSearches.delete(key);
245
- }
246
-
247
246
  // Clean up file upload handler when no connections remain
248
247
  if (this.allConnections.size === 0 && this.fileUploadHandler) {
249
248
  this.fileUploadHandler.destroy();
@@ -251,6 +250,26 @@ export class WebSocketImproviseHandler implements HandlerContext {
251
250
  }
252
251
  }
253
252
 
253
+ private cleanupConnectionResources(tabMap: Map<string, string>): void {
254
+ // Destroy sessions owned by this connection
255
+ const sessionIds = new Set(tabMap.values());
256
+ for (const sessionId of sessionIds) {
257
+ const session = this.sessions.get(sessionId);
258
+ if (session) {
259
+ session.destroy();
260
+ this.sessions.delete(sessionId);
261
+ }
262
+ }
263
+ // Kill search processes owned by this connection's tabs
264
+ for (const tabId of tabMap.keys()) {
265
+ const searchProcess = this.activeSearches.get(tabId);
266
+ if (searchProcess) {
267
+ try { searchProcess.kill(); } catch { /* ignore */ }
268
+ this.activeSearches.delete(tabId);
269
+ }
270
+ }
271
+ }
272
+
254
273
  send(ws: WSContext, response: WebSocketResponse): void {
255
274
  try {
256
275
  ws.send(JSON.stringify(response));
@@ -2,6 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
5
+ import { readFile } from 'node:fs/promises';
5
6
  import { join } from 'node:path';
6
7
  import type { HandlerContext } from './handler-context.js';
7
8
  import type { WebSocketMessage, WSContext } from './types.js';
@@ -9,8 +10,9 @@ import type { WebSocketMessage, WSContext } from './types.js';
9
10
  export function handleHistoryMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
10
11
  switch (msg.type) {
11
12
  case 'getSessions': {
12
- const result = getSessionsList(workingDir, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
13
- ctx.send(ws, { type: 'sessions', tabId, data: result });
13
+ getSessionsList(workingDir, msg.data?.limit ?? 20, msg.data?.offset ?? 0).then(result => {
14
+ ctx.send(ws, { type: 'sessions', tabId, data: result });
15
+ });
14
16
  break;
15
17
  }
16
18
  case 'getSessionsCount':
@@ -42,7 +44,7 @@ function getSessionsCount(workingDir: string): number {
42
44
  return readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json')).length;
43
45
  }
44
46
 
45
- function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: Array<Record<string, unknown> | null>; total: number; hasMore: boolean } {
47
+ async function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): Promise<{ sessions: Array<Record<string, unknown> | null>; total: number; hasMore: boolean }> {
46
48
  const sessionsDir = join(workingDir, '.mstro', 'history');
47
49
 
48
50
  if (!existsSync(sessionsDir)) {
@@ -60,17 +62,17 @@ function getSessionsList(workingDir: string, limit: number = 20, offset: number
60
62
  const total = historyFiles.length;
61
63
  const pageFiles = historyFiles.slice(offset, offset + limit);
62
64
 
63
- const sessions = pageFiles.map((filename: string) => {
65
+ const sessions = await Promise.all(pageFiles.map(async (filename: string) => {
64
66
  const historyPath = join(sessionsDir, filename);
65
67
  try {
66
- const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
67
- return buildSessionSummary(historyData);
68
+ const raw = await readFile(historyPath, 'utf-8');
69
+ return buildSessionSummary(JSON.parse(raw));
68
70
  } catch {
69
71
  return null;
70
72
  }
71
- }).filter(Boolean);
73
+ }));
72
74
 
73
- return { sessions, total, hasMore: offset + limit < total };
75
+ return { sessions: sessions.filter(Boolean), total, hasMore: offset + limit < total };
74
76
  }
75
77
 
76
78
  function getSessionById(workingDir: string, sessionId: string): Record<string, unknown> | null {
@@ -0,0 +1,90 @@
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 } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { findSkillsDir } from '../../utils/paths.js';
8
+ import type { HandlerContext } from './handler-context.js';
9
+ import type { SkillEntry, WSContext } from './types.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const SYSTEM_AGENTS_DIR = join(__dirname, '..', 'plan', 'agents');
13
+
14
+ function parseFrontmatter(content: string): Record<string, string> {
15
+ if (!content.startsWith('---')) return {};
16
+ const endIdx = content.indexOf('---', 3);
17
+ if (endIdx === -1) return {};
18
+ const yaml = content.slice(3, endIdx).trim();
19
+ const result: Record<string, string> = {};
20
+ for (const line of yaml.split('\n')) {
21
+ const colonIdx = line.indexOf(':');
22
+ if (colonIdx === -1) continue;
23
+ const key = line.slice(0, colonIdx).trim();
24
+ let val = line.slice(colonIdx + 1).trim();
25
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
26
+ val = val.slice(1, -1);
27
+ }
28
+ result[key] = val;
29
+ }
30
+ return result;
31
+ }
32
+
33
+ function scanProjectSkills(skillsDir: string): SkillEntry[] {
34
+ if (!existsSync(skillsDir)) return [];
35
+ const entries: SkillEntry[] = [];
36
+ for (const name of readdirSync(skillsDir, { withFileTypes: true })) {
37
+ if (!name.isDirectory()) continue;
38
+ const skillFile = join(skillsDir, name.name, 'SKILL.md');
39
+ if (!existsSync(skillFile)) continue;
40
+ try {
41
+ const content = readFileSync(skillFile, 'utf-8');
42
+ const fm = parseFrontmatter(content);
43
+ if (fm['user-invocable'] === 'false') continue;
44
+ entries.push({
45
+ name: fm.name || name.name,
46
+ displayName: `/${fm.name || name.name}`,
47
+ description: fm.description || '',
48
+ source: 'project',
49
+ });
50
+ } catch { /* skip unreadable files */ }
51
+ }
52
+ return entries;
53
+ }
54
+
55
+ function scanSystemAgents(agentsDir: string, seen: Set<string>): SkillEntry[] {
56
+ if (!existsSync(agentsDir)) return [];
57
+ const entries: SkillEntry[] = [];
58
+ for (const file of readdirSync(agentsDir)) {
59
+ if (!file.endsWith('.md')) continue;
60
+ const name = file.replace(/\.md$/, '');
61
+ if (seen.has(name)) continue;
62
+ try {
63
+ const content = readFileSync(join(agentsDir, file), 'utf-8');
64
+ const fm = parseFrontmatter(content);
65
+ if (fm['user-invocable'] === 'false') continue;
66
+ entries.push({
67
+ name: fm.name || name,
68
+ displayName: `/${fm.name || name}`,
69
+ description: fm.description || '',
70
+ source: 'system',
71
+ });
72
+ } catch { /* skip unreadable files */ }
73
+ }
74
+ return entries;
75
+ }
76
+
77
+ export function handleListSkills(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
78
+ const skills: SkillEntry[] = [];
79
+
80
+ const projectSkillsDir = findSkillsDir(workingDir);
81
+ if (projectSkillsDir) {
82
+ skills.push(...scanProjectSkills(projectSkillsDir));
83
+ }
84
+
85
+ const seen = new Set(skills.map(s => s.name));
86
+ skills.push(...scanSystemAgents(SYSTEM_AGENTS_DIR, seen));
87
+
88
+ skills.sort((a, b) => a.name.localeCompare(b.name));
89
+ ctx.send(ws, { type: 'skillsList', data: { skills } });
90
+ }
@@ -168,7 +168,9 @@ export interface WebSocketMessage {
168
168
  | 'deployHttpRequest'
169
169
  // Deploy usage/health message types (cli→server)
170
170
  | 'deployUsageReport'
171
- | 'deployAiHealthUpdate';
171
+ | 'deployAiHealthUpdate'
172
+ // Skill discovery
173
+ | 'listSkills';
172
174
  tabId?: string;
173
175
  terminalId?: string;
174
176
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
@@ -346,7 +348,9 @@ export interface WebSocketResponse {
346
348
  | 'deployHttpResponseChunk'
347
349
  | 'deployStatus'
348
350
  | 'deployUsageReportAck'
349
- | 'deployAiHealthAck';
351
+ | 'deployAiHealthAck'
352
+ // Skill discovery response types
353
+ | 'skillsList';
350
354
  tabId?: string;
351
355
  terminalId?: string;
352
356
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
@@ -358,6 +362,13 @@ export interface ConnectionData {
358
362
  workingDir: string;
359
363
  }
360
364
 
365
+ export interface SkillEntry {
366
+ name: string;
367
+ displayName: string;
368
+ description: string;
369
+ source: 'project' | 'system' | 'builtin';
370
+ }
371
+
361
372
  // Extended autocomplete option with metadata
362
373
  export interface AutocompleteResult {
363
374
  value: string;
@@ -8,7 +8,8 @@
8
8
  * Works correctly whether running from source or installed globally.
9
9
  */
10
10
 
11
- import { dirname, resolve } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { dirname, join, resolve } from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
14
 
14
15
  // ES module equivalent of __dirname for this file
@@ -29,3 +30,18 @@ export const MSTRO_ROOT = resolve(__dirname, '../..');
29
30
  */
30
31
  export const MCP_SERVER_PATH = resolve(MSTRO_ROOT, 'server/mcp/server.ts');
31
32
 
33
+ /**
34
+ * Walk up from startDir looking for `.claude/skills/`. Returns the path if found, null otherwise.
35
+ */
36
+ export function findSkillsDir(startDir: string): string | null {
37
+ let dir = startDir;
38
+ for (let i = 0; i < 10; i++) {
39
+ const candidate = join(dir, '.claude', 'skills');
40
+ if (existsSync(candidate)) return candidate;
41
+ const parent = dirname(dir);
42
+ if (parent === dir) break;
43
+ dir = parent;
44
+ }
45
+ return null;
46
+ }
47
+