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.
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +18 -1
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +5 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +41 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/files.d.ts.map +1 -1
- package/dist/server/services/files.js +6 -2
- package/dist/server/services/files.js.map +1 -1
- package/dist/server/services/plan/agent-loader.d.ts.map +1 -1
- package/dist/server/services/plan/agent-loader.js +1 -17
- package/dist/server/services/plan/agent-loader.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +2 -2
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +3 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +8 -3
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +15 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +5 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js +23 -2
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +15 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +47 -8
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +2 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +36 -18
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +4 -2
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.js +9 -0
- package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-history.d.ts.map +1 -1
- package/dist/server/services/websocket/session-history.js +10 -8
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +4 -0
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/skill-handlers.js +93 -0
- package/dist/server/services/websocket/skill-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +8 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/paths.d.ts +4 -0
- package/dist/server/utils/paths.d.ts.map +1 -1
- package/dist/server/utils/paths.js +18 -1
- package/dist/server/utils/paths.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/improvisation-retry.ts +19 -1
- package/server/cli/improvisation-session-manager.ts +44 -1
- package/server/services/files.ts +7 -2
- package/server/services/plan/agent-loader.ts +1 -16
- package/server/services/plan/composer.ts +2 -1
- package/server/services/plan/executor.ts +8 -3
- package/server/services/plan/parser-core.ts +14 -0
- package/server/services/plan/types.ts +6 -0
- package/server/services/websocket/file-explorer-handlers.ts +23 -2
- package/server/services/websocket/git-handlers.ts +17 -0
- package/server/services/websocket/git-worktree-handlers.ts +45 -9
- package/server/services/websocket/handler.ts +35 -16
- package/server/services/websocket/plan-execution-handlers.ts +4 -2
- package/server/services/websocket/plan-issue-handlers.ts +10 -0
- package/server/services/websocket/session-history.ts +10 -8
- package/server/services/websocket/skill-handlers.ts +90 -0
- package/server/services/websocket/types.ts +13 -2
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
67
|
-
return buildSessionSummary(
|
|
68
|
+
const raw = await readFile(historyPath, 'utf-8');
|
|
69
|
+
return buildSessionSummary(JSON.parse(raw));
|
|
68
70
|
} catch {
|
|
69
71
|
return null;
|
|
70
72
|
}
|
|
71
|
-
})
|
|
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;
|
package/server/utils/paths.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* Works correctly whether running from source or installed globally.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
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
|
+
|