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.
- 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/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/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/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/websocket/file-explorer-handlers.ts +23 -2
- package/server/services/websocket/git-handlers.ts +17 -0
- package/server/services/websocket/handler.ts +35 -16
- 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
|
@@ -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 {
|
|
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;
|
|
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
|
@@ -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
|
|
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(); },
|
|
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 {
|
package/server/services/files.ts
CHANGED
|
@@ -115,8 +115,13 @@ export class FileService {
|
|
|
115
115
|
isDirectory: entry.isDirectory()
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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.
|
|
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));
|
|
@@ -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
|
+
|