mstro-app 0.4.32 → 0.4.34

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 (77) 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/plan/composer.d.ts +1 -1
  15. package/dist/server/services/plan/composer.d.ts.map +1 -1
  16. package/dist/server/services/plan/composer.js +2 -2
  17. package/dist/server/services/plan/composer.js.map +1 -1
  18. package/dist/server/services/plan/executor.d.ts +3 -1
  19. package/dist/server/services/plan/executor.d.ts.map +1 -1
  20. package/dist/server/services/plan/executor.js +8 -3
  21. package/dist/server/services/plan/executor.js.map +1 -1
  22. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  23. package/dist/server/services/plan/parser-core.js +15 -0
  24. package/dist/server/services/plan/parser-core.js.map +1 -1
  25. package/dist/server/services/plan/types.d.ts +5 -0
  26. package/dist/server/services/plan/types.d.ts.map +1 -1
  27. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  28. package/dist/server/services/websocket/file-explorer-handlers.js +23 -2
  29. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  30. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  31. package/dist/server/services/websocket/git-handlers.js +15 -0
  32. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  33. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  34. package/dist/server/services/websocket/git-worktree-handlers.js +47 -8
  35. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  36. package/dist/server/services/websocket/handler.d.ts +2 -0
  37. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  38. package/dist/server/services/websocket/handler.js +36 -18
  39. package/dist/server/services/websocket/handler.js.map +1 -1
  40. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
  41. package/dist/server/services/websocket/plan-execution-handlers.js +4 -2
  42. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  43. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  44. package/dist/server/services/websocket/plan-issue-handlers.js +9 -0
  45. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  46. package/dist/server/services/websocket/session-history.d.ts.map +1 -1
  47. package/dist/server/services/websocket/session-history.js +10 -8
  48. package/dist/server/services/websocket/session-history.js.map +1 -1
  49. package/dist/server/services/websocket/skill-handlers.d.ts +4 -0
  50. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -0
  51. package/dist/server/services/websocket/skill-handlers.js +93 -0
  52. package/dist/server/services/websocket/skill-handlers.js.map +1 -0
  53. package/dist/server/services/websocket/types.d.ts +8 -2
  54. package/dist/server/services/websocket/types.d.ts.map +1 -1
  55. package/dist/server/utils/paths.d.ts +4 -0
  56. package/dist/server/utils/paths.d.ts.map +1 -1
  57. package/dist/server/utils/paths.js +18 -1
  58. package/dist/server/utils/paths.js.map +1 -1
  59. package/package.json +1 -1
  60. package/server/cli/improvisation-retry.ts +19 -1
  61. package/server/cli/improvisation-session-manager.ts +44 -1
  62. package/server/services/files.ts +7 -2
  63. package/server/services/plan/agent-loader.ts +1 -16
  64. package/server/services/plan/composer.ts +2 -1
  65. package/server/services/plan/executor.ts +8 -3
  66. package/server/services/plan/parser-core.ts +14 -0
  67. package/server/services/plan/types.ts +6 -0
  68. package/server/services/websocket/file-explorer-handlers.ts +23 -2
  69. package/server/services/websocket/git-handlers.ts +17 -0
  70. package/server/services/websocket/git-worktree-handlers.ts +45 -9
  71. package/server/services/websocket/handler.ts +35 -16
  72. package/server/services/websocket/plan-execution-handlers.ts +4 -2
  73. package/server/services/websocket/plan-issue-handlers.ts +10 -0
  74. package/server/services/websocket/session-history.ts +10 -8
  75. package/server/services/websocket/skill-handlers.ts +90 -0
  76. package/server/services/websocket/types.ts +13 -2
  77. package/server/utils/paths.ts +17 -1
@@ -1,11 +1,35 @@
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 { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
5
  import { dirname, join } from 'node:path';
6
+ import { resolvePmDir } from '../plan/parser.js';
7
+ import type { Workspace } from '../plan/types.js';
5
8
  import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handlers.js';
6
9
  import type { HandlerContext } from './handler-context.js';
7
10
  import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
8
11
 
12
+ function persistBoardWorktree(workingDir: string, boardId: string, worktreePath: string | null, branch: string | null): void {
13
+ const pmDir = resolvePmDir(workingDir);
14
+ if (!pmDir) return;
15
+ const wsPath = join(pmDir, 'workspace.json');
16
+ if (!existsSync(wsPath)) return;
17
+ try {
18
+ const workspace: Workspace = JSON.parse(readFileSync(wsPath, 'utf-8'));
19
+ if (!workspace.boardWorktrees) workspace.boardWorktrees = {};
20
+ if (worktreePath && branch) {
21
+ workspace.boardWorktrees[boardId] = { path: worktreePath, branch };
22
+ } else {
23
+ delete workspace.boardWorktrees[boardId];
24
+ }
25
+ writeFileSync(wsPath, JSON.stringify(workspace, null, 2), 'utf-8');
26
+ } catch { /* non-fatal */ }
27
+ }
28
+
29
+ function isBoardId(id: string): boolean {
30
+ return id.startsWith('BOARD-');
31
+ }
32
+
9
33
  export async function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
10
34
  const handlers: Record<string, () => Promise<void>> = {
11
35
  gitWorktreeList: () => handleGitWorktreeList(ctx, ws, tabId, gitDir),
@@ -116,6 +140,9 @@ async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSConte
116
140
  ctx.gitBranches.set(tabId, branchName);
117
141
  const registry = ctx.getRegistry(workingDir);
118
142
  registry.updateTabWorktree(tabId, wtPath, branchName);
143
+ if (isBoardId(tabId)) {
144
+ persistBoardWorktree(workingDir, tabId, wtPath, branchName);
145
+ }
119
146
 
120
147
  ctx.send(ws, {
121
148
  type: 'gitWorktreeCreatedAndAssigned',
@@ -129,6 +156,17 @@ async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSConte
129
156
  }
130
157
  }
131
158
 
159
+ function cleanupWorktreeReferences(ctx: HandlerContext, workingDir: string, wtPath: string): void {
160
+ const resolvedWtPath = join(wtPath);
161
+ for (const [tid, dir] of ctx.gitDirectories) {
162
+ if (dir === resolvedWtPath || dir === wtPath) {
163
+ ctx.gitDirectories.delete(tid);
164
+ ctx.gitBranches.delete(tid);
165
+ if (isBoardId(tid)) persistBoardWorktree(workingDir, tid, null, null);
166
+ }
167
+ }
168
+ }
169
+
132
170
  async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
133
171
  try {
134
172
  const { path: wtPath, force, deleteBranch } = msg.data || {};
@@ -155,15 +193,7 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
155
193
  }
156
194
 
157
195
  await executeGitCommand(['worktree', 'prune'], workingDir);
158
-
159
- // Clean up gitDirectories entries for any tabs referencing the removed worktree
160
- const resolvedWtPath = join(wtPath); // normalize
161
- for (const [tid, dir] of ctx.gitDirectories) {
162
- if (dir === resolvedWtPath || dir === wtPath) {
163
- ctx.gitDirectories.delete(tid);
164
- ctx.gitBranches.delete(tid);
165
- }
166
- }
196
+ cleanupWorktreeReferences(ctx, workingDir, wtPath);
167
197
 
168
198
  ctx.send(ws, { type: 'gitWorktreeRemoved', tabId, data: { path: wtPath } });
169
199
  } catch (error: unknown) {
@@ -180,6 +210,9 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
180
210
  ctx.gitDirectories.delete(resolvedTabId);
181
211
  ctx.gitBranches.delete(resolvedTabId);
182
212
  registry.updateTabWorktree(resolvedTabId, null, null);
213
+ if (isBoardId(resolvedTabId)) {
214
+ persistBoardWorktree(workingDir, resolvedTabId, null, null);
215
+ }
183
216
  ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: workingDir, branch: '' } });
184
217
  handleGitStatus(ctx, ws, resolvedTabId, workingDir);
185
218
  return;
@@ -191,6 +224,9 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
191
224
  const branch = branchResult.stdout.trim();
192
225
  ctx.gitBranches.set(resolvedTabId, branch);
193
226
  registry.updateTabWorktree(resolvedTabId, worktreePath, branch);
227
+ if (isBoardId(resolvedTabId)) {
228
+ persistBoardWorktree(workingDir, resolvedTabId, worktreePath, branch);
229
+ }
194
230
 
195
231
  ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
196
232
  handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
@@ -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));
@@ -24,7 +24,8 @@ export function handlePrompt(
24
24
  ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
25
25
  return;
26
26
  }
27
- handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
27
+ const executionDir = boardId ? ctx.gitDirectories.get(boardId) : undefined;
28
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, executionDir).catch(error => {
28
29
  ctx.send(ws, {
29
30
  type: 'planError',
30
31
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -109,8 +110,9 @@ export function handleExecute(
109
110
  wireExecutorEvents(executor, ctx, workingDir);
110
111
 
111
112
  const boardId = msg.data?.boardId as string | undefined;
113
+ const executionDir = boardId ? ctx.gitDirectories.get(boardId) : undefined;
112
114
  ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
113
- const startPromise = boardId ? executor.startBoard(boardId) : executor.start();
115
+ const startPromise = boardId ? executor.startBoard(boardId, executionDir) : executor.start();
114
116
  startPromise.catch(error => {
115
117
  ctx.send(ws, {
116
118
  type: 'planExecutionError',
@@ -26,6 +26,16 @@ export function handlePlanInit(ctx: HandlerContext, ws: WSContext, workingDir: s
26
26
  return;
27
27
  }
28
28
 
29
+ // Restore board worktree assignments into runtime maps
30
+ if (fullState.workspace?.boardWorktrees) {
31
+ for (const [boardId, entry] of Object.entries(fullState.workspace.boardWorktrees)) {
32
+ if (!ctx.gitDirectories.has(boardId)) {
33
+ ctx.gitDirectories.set(boardId, entry.path);
34
+ ctx.gitBranches.set(boardId, entry.branch);
35
+ }
36
+ }
37
+ }
38
+
29
39
  ctx.send(ws, { type: 'planState', data: fullState });
30
40
 
31
41
  const watcher = getWatcher(workingDir, ctx);
@@ -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
+