mstro-app 0.3.0 → 0.3.1
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/bin/mstro.js +65 -2
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +4 -3
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +2 -2
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +6 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +36 -4
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +1 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +2 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +3 -2
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +6 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.js +53 -14
- package/dist/server/mcp/bouncer-cli.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +70 -7
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +3 -3
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/server.js +3 -2
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -2
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/files.js +7 -7
- package/dist/server/services/files.js.map +1 -1
- package/dist/server/services/pathUtils.js +1 -1
- package/dist/server/services/pathUtils.js.map +1 -1
- package/dist/server/services/platform.d.ts +2 -2
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sentry.d.ts +1 -1
- package/dist/server/services/sentry.d.ts.map +1 -1
- package/dist/server/services/sentry.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +10 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +32 -4
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/file-utils.d.ts +4 -0
- package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
- package/dist/server/services/websocket/file-utils.js +27 -8
- package/dist/server/services/websocket/file-utils.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +17 -17
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.js +3 -3
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +10 -10
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.js +1 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +12 -11
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +1 -1
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.d.ts +22 -2
- package/dist/server/utils/agent-manager.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.js +2 -2
- package/dist/server/utils/agent-manager.js.map +1 -1
- package/dist/server/utils/port-manager.js.map +1 -1
- package/hooks/bouncer.sh +17 -3
- package/package.json +4 -2
- package/server/cli/headless/claude-invoker.ts +21 -16
- package/server/cli/headless/mcp-config.ts +8 -8
- package/server/cli/headless/runner.ts +32 -4
- package/server/cli/headless/types.ts +1 -1
- package/server/cli/improvisation-session-manager.ts +8 -7
- package/server/index.ts +15 -9
- package/server/mcp/bouncer-cli.ts +73 -20
- package/server/mcp/bouncer-integration.ts +99 -16
- package/server/mcp/security-audit.ts +4 -4
- package/server/mcp/server.ts +6 -5
- package/server/services/analytics.ts +3 -3
- package/server/services/files.ts +13 -13
- package/server/services/pathUtils.ts +2 -2
- package/server/services/platform.ts +5 -5
- package/server/services/sentry.ts +1 -1
- package/server/services/terminal/pty-manager.ts +36 -9
- package/server/services/websocket/file-explorer-handlers.ts +1 -1
- package/server/services/websocket/file-utils.ts +28 -9
- package/server/services/websocket/git-handlers.ts +34 -34
- package/server/services/websocket/git-pr-handlers.ts +6 -6
- package/server/services/websocket/git-worktree-handlers.ts +20 -20
- package/server/services/websocket/handler.ts +2 -2
- package/server/services/websocket/session-handlers.ts +31 -30
- package/server/services/websocket/tab-handlers.ts +1 -1
- package/server/services/websocket/terminal-handlers.ts +2 -2
- package/server/services/websocket/types.ts +2 -0
- package/server/utils/agent-manager.ts +6 -6
- package/server/utils/port-manager.ts +1 -1
- package/server/cli/headless/output-utils.test.ts +0 -225
- package/server/cli/headless/stall-assessor.test.ts +0 -165
- package/server/cli/headless/tool-watchdog.test.ts +0 -429
- package/server/mcp/bouncer-integration.test.ts +0 -161
- package/server/mcp/security-patterns.test.ts +0 -258
- package/server/services/platform.test.ts +0 -1304
- package/server/services/websocket/autocomplete.test.ts +0 -194
- package/server/services/websocket/handler.test.ts +0 -20
|
@@ -10,8 +10,8 @@ import type { SessionRegistry } from './session-registry.js';
|
|
|
10
10
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
11
11
|
|
|
12
12
|
/** Convert tool history entries into OutputLine-compatible lines */
|
|
13
|
-
function convertToolHistoryToLines(tools:
|
|
14
|
-
const lines:
|
|
13
|
+
function convertToolHistoryToLines(tools: Array<{ toolName: string; toolInput?: Record<string, unknown>; result?: string; isError?: boolean }>, ts: number): Array<Record<string, unknown>> {
|
|
14
|
+
const lines: Array<Record<string, unknown>> = [];
|
|
15
15
|
for (const tool of tools) {
|
|
16
16
|
lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
|
|
17
17
|
if (tool.result !== undefined) {
|
|
@@ -22,8 +22,8 @@ function convertToolHistoryToLines(tools: any[], ts: number): any[] {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Convert a single movement record into OutputLine-compatible entries */
|
|
25
|
-
function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?:
|
|
26
|
-
const lines:
|
|
25
|
+
function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: Array<{ toolName: string; toolInput?: Record<string, unknown>; result?: string; isError?: boolean }>; assistantResponse?: string; errorOutput?: string; tokensUsed: number; durationMs?: number }): Array<Record<string, unknown>> {
|
|
26
|
+
const lines: Array<Record<string, unknown>> = [];
|
|
27
27
|
const ts = new Date(movement.timestamp).getTime();
|
|
28
28
|
|
|
29
29
|
lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
|
|
@@ -67,7 +67,7 @@ function getSession(ctx: HandlerContext, ws: WSContext, tabId: string): Improvis
|
|
|
67
67
|
return ctx.sessions.get(sessionId) || null;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export function buildOutputHistory(session: ImprovisationSessionManager):
|
|
70
|
+
export function buildOutputHistory(session: ImprovisationSessionManager): Array<Record<string, unknown>> {
|
|
71
71
|
const history = session.getHistory();
|
|
72
72
|
return history.movements.flatMap(convertMovementToLines);
|
|
73
73
|
}
|
|
@@ -88,7 +88,7 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
|
|
|
88
88
|
ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
session.on('onMovementComplete', (movement:
|
|
91
|
+
session.on('onMovementComplete', (movement: Record<string, unknown>) => {
|
|
92
92
|
ctx.send(ws, { type: 'movementComplete', tabId, data: movement });
|
|
93
93
|
|
|
94
94
|
const registry = ctx.getRegistry('');
|
|
@@ -99,7 +99,7 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
|
|
|
99
99
|
|
|
100
100
|
if (ctx.usageReporter && movement.tokensUsed) {
|
|
101
101
|
ctx.usageReporter({
|
|
102
|
-
tokensUsed: movement.tokensUsed,
|
|
102
|
+
tokensUsed: movement.tokensUsed as number,
|
|
103
103
|
sessionId: session.getSessionInfo().sessionId,
|
|
104
104
|
movementId: `${movement.sequenceNumber}`
|
|
105
105
|
});
|
|
@@ -111,15 +111,15 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
|
|
|
111
111
|
ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
session.on('onSessionUpdate', (history:
|
|
114
|
+
session.on('onSessionUpdate', (history: Record<string, unknown>) => {
|
|
115
115
|
ctx.send(ws, { type: 'sessionUpdate', tabId, data: history });
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
session.on('onPlanNeedsConfirmation', (plan:
|
|
118
|
+
session.on('onPlanNeedsConfirmation', (plan: Record<string, unknown>) => {
|
|
119
119
|
ctx.send(ws, { type: 'approvalRequired', tabId, data: plan });
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
session.on('onToolUse', (event:
|
|
122
|
+
session.on('onToolUse', (event: Record<string, unknown>) => {
|
|
123
123
|
ctx.send(ws, { type: 'toolUse', tabId, data: { ...event, timestamp: Date.now() } });
|
|
124
124
|
});
|
|
125
125
|
|
|
@@ -163,13 +163,13 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
163
163
|
}
|
|
164
164
|
case 'approve': {
|
|
165
165
|
const session = requireSession(ctx, ws, tabId);
|
|
166
|
-
|
|
166
|
+
session.respondToApproval(true);
|
|
167
167
|
ctx.send(ws, { type: 'output', tabId, data: { text: '\n✅ Approved - proceeding with operation\n' } });
|
|
168
168
|
break;
|
|
169
169
|
}
|
|
170
170
|
case 'reject': {
|
|
171
171
|
const session = requireSession(ctx, ws, tabId);
|
|
172
|
-
|
|
172
|
+
session.respondToApproval(false);
|
|
173
173
|
ctx.send(ws, { type: 'output', tabId, data: { text: '\n🚫 Rejected - operation cancelled\n' } });
|
|
174
174
|
break;
|
|
175
175
|
}
|
|
@@ -309,8 +309,8 @@ export async function resumeHistoricalSession(
|
|
|
309
309
|
|
|
310
310
|
try {
|
|
311
311
|
session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
|
|
312
|
-
} catch (error:
|
|
313
|
-
console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
|
|
312
|
+
} catch (error: unknown) {
|
|
313
|
+
console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error instanceof Error ? error.message : String(error)}. Creating new session.`);
|
|
314
314
|
session = new ImprovisationSessionManager({ workingDir, model: getModel() });
|
|
315
315
|
isNewSession = true;
|
|
316
316
|
}
|
|
@@ -381,7 +381,7 @@ function getSessionsCount(workingDir: string): number {
|
|
|
381
381
|
return readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json')).length;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
-
function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions:
|
|
384
|
+
function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: Array<Record<string, unknown> | null>; total: number; hasMore: boolean } {
|
|
385
385
|
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
386
386
|
|
|
387
387
|
if (!existsSync(sessionsDir)) {
|
|
@@ -405,8 +405,8 @@ function getSessionsList(workingDir: string, limit: number = 20, offset: number
|
|
|
405
405
|
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
406
406
|
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
407
407
|
|
|
408
|
-
const movementPreviews = (historyData.movements || []).slice(0, 3).map((m:
|
|
409
|
-
userPrompt: m.userPrompt
|
|
408
|
+
const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: Record<string, unknown>) => ({
|
|
409
|
+
userPrompt: (typeof m.userPrompt === 'string' ? m.userPrompt : '').slice(0, 100) || ''
|
|
410
410
|
}));
|
|
411
411
|
|
|
412
412
|
return {
|
|
@@ -426,7 +426,7 @@ function getSessionsList(workingDir: string, limit: number = 20, offset: number
|
|
|
426
426
|
return { sessions, total, hasMore: offset + limit < total };
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
-
function getSessionById(workingDir: string, sessionId: string):
|
|
429
|
+
function getSessionById(workingDir: string, sessionId: string): Record<string, unknown> | null {
|
|
430
430
|
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
431
431
|
if (!existsSync(sessionsDir)) return null;
|
|
432
432
|
|
|
@@ -508,32 +508,33 @@ function clearAllSessions(workingDir: string): { success: boolean; deletedCount:
|
|
|
508
508
|
}
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
-
function movementMatchesQuery(movements:
|
|
511
|
+
function movementMatchesQuery(movements: Array<Record<string, unknown>> | undefined, lowerQuery: string): boolean {
|
|
512
512
|
if (!movements) return false;
|
|
513
|
-
return movements.some((m:
|
|
514
|
-
m.userPrompt
|
|
515
|
-
m.summary
|
|
516
|
-
m.assistantResponse
|
|
513
|
+
return movements.some((m: Record<string, unknown>) =>
|
|
514
|
+
(typeof m.userPrompt === 'string' && m.userPrompt.toLowerCase().includes(lowerQuery)) ||
|
|
515
|
+
(typeof m.summary === 'string' && m.summary.toLowerCase().includes(lowerQuery)) ||
|
|
516
|
+
(typeof m.assistantResponse === 'string' && m.assistantResponse.toLowerCase().includes(lowerQuery))
|
|
517
517
|
);
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
-
function buildSessionSummary(historyData:
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
|
|
520
|
+
function buildSessionSummary(historyData: Record<string, unknown>): Record<string, unknown> {
|
|
521
|
+
const movements = historyData.movements as Array<Record<string, unknown>> | undefined;
|
|
522
|
+
const firstPrompt = (typeof movements?.[0]?.userPrompt === 'string' ? movements[0].userPrompt : '') || '';
|
|
523
|
+
const movementPreviews = (movements || []).slice(0, 3).map((m: Record<string, unknown>) => ({
|
|
524
|
+
userPrompt: (typeof m.userPrompt === 'string' ? m.userPrompt : '').slice(0, 100) || ''
|
|
524
525
|
}));
|
|
525
526
|
return {
|
|
526
527
|
sessionId: historyData.sessionId,
|
|
527
528
|
startedAt: historyData.startedAt,
|
|
528
529
|
lastActivityAt: historyData.lastActivityAt,
|
|
529
530
|
totalTokens: historyData.totalTokens,
|
|
530
|
-
movementCount:
|
|
531
|
+
movementCount: movements?.length || 0,
|
|
531
532
|
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
532
533
|
movements: movementPreviews
|
|
533
534
|
};
|
|
534
535
|
}
|
|
535
536
|
|
|
536
|
-
function searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions:
|
|
537
|
+
function searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: Array<Record<string, unknown>>; total: number; hasMore: boolean } {
|
|
537
538
|
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
538
539
|
if (!existsSync(sessionsDir)) return { sessions: [], total: 0, hasMore: false };
|
|
539
540
|
|
|
@@ -548,7 +549,7 @@ function searchSessions(workingDir: string, query: string, limit: number = 20, o
|
|
|
548
549
|
return timestampB - timestampA;
|
|
549
550
|
});
|
|
550
551
|
|
|
551
|
-
const allMatches:
|
|
552
|
+
const allMatches: Array<Record<string, unknown>> = [];
|
|
552
553
|
for (const filename of historyFiles) {
|
|
553
554
|
try {
|
|
554
555
|
const content = readFileSync(join(sessionsDir, filename), 'utf-8');
|
|
@@ -11,7 +11,7 @@ export function handleGetActiveTabs(ctx: HandlerContext, ws: WSContext, workingD
|
|
|
11
11
|
const registry = ctx.getRegistry(workingDir);
|
|
12
12
|
const allTabs = registry.getAllTabs();
|
|
13
13
|
|
|
14
|
-
const tabs: Record<string,
|
|
14
|
+
const tabs: Record<string, unknown> = {};
|
|
15
15
|
for (const [tabId, regTab] of Object.entries(allTabs)) {
|
|
16
16
|
const session = ctx.sessions.get(regTab.sessionId);
|
|
17
17
|
if (session) {
|
|
@@ -83,12 +83,12 @@ function handleTerminalInit(
|
|
|
83
83
|
shell,
|
|
84
84
|
is_reconnect: isReconnect,
|
|
85
85
|
});
|
|
86
|
-
} catch (error:
|
|
86
|
+
} catch (error: unknown) {
|
|
87
87
|
console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
|
|
88
88
|
ctx.send(ws, {
|
|
89
89
|
type: 'terminalError',
|
|
90
90
|
terminalId,
|
|
91
|
-
data: { error: error.message || 'Failed to create terminal' }
|
|
91
|
+
data: { error: (error instanceof Error ? error.message : String(error)) || 'Failed to create terminal' }
|
|
92
92
|
});
|
|
93
93
|
removeTerminalSubscriber(ctx, terminalId, ws);
|
|
94
94
|
}
|
|
@@ -108,6 +108,7 @@ export interface WebSocketMessage {
|
|
|
108
108
|
| 'updateSettings';
|
|
109
109
|
tabId?: string;
|
|
110
110
|
terminalId?: string;
|
|
111
|
+
// biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
|
|
111
112
|
data?: any;
|
|
112
113
|
/** Injected by server relay for sandboxed shared users (control + view) */
|
|
113
114
|
_permission?: 'control' | 'view';
|
|
@@ -211,6 +212,7 @@ export interface WebSocketResponse {
|
|
|
211
212
|
| 'settingsUpdated';
|
|
212
213
|
tabId?: string;
|
|
213
214
|
terminalId?: string;
|
|
215
|
+
// biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
|
|
214
216
|
data?: any;
|
|
215
217
|
}
|
|
216
218
|
|
|
@@ -289,14 +289,14 @@ export class AgentManager {
|
|
|
289
289
|
/**
|
|
290
290
|
* Extract agent names from a score object
|
|
291
291
|
*/
|
|
292
|
-
extractAgentNamesFromScore(score:
|
|
292
|
+
extractAgentNamesFromScore(score: { movements?: Array<{ musicians?: Array<{ type?: string; config?: { agent?: string }; role?: string }> }> }): string[] {
|
|
293
293
|
if (!Array.isArray(score.movements)) return [];
|
|
294
294
|
|
|
295
|
-
const names =
|
|
295
|
+
const names = score.movements
|
|
296
296
|
.flatMap(m => Array.isArray(m.musicians) ? m.musicians : [])
|
|
297
|
-
.filter(
|
|
298
|
-
.map(
|
|
299
|
-
.filter(Boolean);
|
|
297
|
+
.filter(m => m.type === 'custom')
|
|
298
|
+
.map(m => m.config?.agent || m.role)
|
|
299
|
+
.filter(Boolean) as string[];
|
|
300
300
|
|
|
301
301
|
return [...new Set<string>(names)];
|
|
302
302
|
}
|
|
@@ -304,7 +304,7 @@ export class AgentManager {
|
|
|
304
304
|
/**
|
|
305
305
|
* Ensure all agents required by a score are available
|
|
306
306
|
*/
|
|
307
|
-
async ensureScoreAgentsAvailable(score:
|
|
307
|
+
async ensureScoreAgentsAvailable(score: { movements?: Array<{ musicians?: Array<{ type?: string; config?: { agent?: string }; role?: string }> }> }, workingDir: string): Promise<Map<string, AgentInfo>> {
|
|
308
308
|
const agentNames = this.extractAgentNamesFromScore(score);
|
|
309
309
|
const results = new Map<string, AgentInfo>();
|
|
310
310
|
|
|
@@ -20,7 +20,7 @@ async function isPortAvailable(port: number): Promise<boolean> {
|
|
|
20
20
|
return new Promise((resolve) => {
|
|
21
21
|
const server = createServer()
|
|
22
22
|
|
|
23
|
-
server.once('error', (err:
|
|
23
|
+
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
24
24
|
if (err.code === 'EADDRINUSE') {
|
|
25
25
|
resolve(false)
|
|
26
26
|
} else {
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
detectErrorInStderr,
|
|
4
|
-
estimateTokensFromOutput,
|
|
5
|
-
extractCleanOutput,
|
|
6
|
-
extractModifiedFiles,
|
|
7
|
-
} from './output-utils.js';
|
|
8
|
-
|
|
9
|
-
// ========== extractCleanOutput ==========
|
|
10
|
-
|
|
11
|
-
describe('extractCleanOutput', () => {
|
|
12
|
-
it('filters out JSON lines with "type" field', () => {
|
|
13
|
-
const input = [
|
|
14
|
-
'{"type": "system", "data": "init"}',
|
|
15
|
-
'Hello world',
|
|
16
|
-
'{"type": "assistant", "text": "hi"}',
|
|
17
|
-
'Some output',
|
|
18
|
-
].join('\n');
|
|
19
|
-
expect(extractCleanOutput(input)).toBe('Hello world\nSome output');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('strips ANSI color codes', () => {
|
|
23
|
-
const input = '\x1b[32mgreen text\x1b[0m and \x1b[1;31mred bold\x1b[0m';
|
|
24
|
-
expect(extractCleanOutput(input)).toBe('green text and red bold');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('normalizes CRLF to LF', () => {
|
|
28
|
-
const input = 'line1\r\nline2\r\nline3';
|
|
29
|
-
expect(extractCleanOutput(input)).toBe('line1\nline2\nline3');
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('trims whitespace', () => {
|
|
33
|
-
const input = ' \n Hello \n ';
|
|
34
|
-
expect(extractCleanOutput(input)).toBe('Hello');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('filters empty lines', () => {
|
|
38
|
-
const input = 'line1\n\n\nline2';
|
|
39
|
-
expect(extractCleanOutput(input)).toBe('line1\nline2');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('returns empty string for all-JSON input', () => {
|
|
43
|
-
const input = '{"type": "system"}\n{"type": "result"}';
|
|
44
|
-
expect(extractCleanOutput(input)).toBe('');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('handles combined ANSI + JSON + CRLF', () => {
|
|
48
|
-
const input = '{"type": "system"}\r\n\x1b[33mwarning\x1b[0m\r\n{"type": "result"}';
|
|
49
|
-
expect(extractCleanOutput(input)).toBe('warning');
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// ========== estimateTokensFromOutput ==========
|
|
54
|
-
|
|
55
|
-
describe('estimateTokensFromOutput', () => {
|
|
56
|
-
it('estimates tokens as length / 4', () => {
|
|
57
|
-
expect(estimateTokensFromOutput('12345678')).toBe(2);
|
|
58
|
-
expect(estimateTokensFromOutput('1234')).toBe(1);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('floors the result', () => {
|
|
62
|
-
expect(estimateTokensFromOutput('12345')).toBe(1); // 5/4 = 1.25 → 1
|
|
63
|
-
expect(estimateTokensFromOutput('123')).toBe(0); // 3/4 = 0.75 → 0
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('returns 0 for empty string', () => {
|
|
67
|
-
expect(estimateTokensFromOutput('')).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// ========== extractModifiedFiles ==========
|
|
72
|
-
|
|
73
|
-
describe('extractModifiedFiles', () => {
|
|
74
|
-
it('extracts files from "wrote" pattern', () => {
|
|
75
|
-
const output = 'wrote file "src/index.ts" successfully';
|
|
76
|
-
expect(extractModifiedFiles(output)).toContain('src/index.ts');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('extracts files from "modified" pattern', () => {
|
|
80
|
-
const output = 'modified utils.js in place';
|
|
81
|
-
expect(extractModifiedFiles(output)).toContain('utils.js');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('extracts files from "created" pattern', () => {
|
|
85
|
-
const output = "created file 'new-file.tsx'";
|
|
86
|
-
expect(extractModifiedFiles(output)).toContain('new-file.tsx');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('extracts files from "edited" pattern', () => {
|
|
90
|
-
const output = 'edited config.json';
|
|
91
|
-
expect(extractModifiedFiles(output)).toContain('config.json');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('deduplicates files', () => {
|
|
95
|
-
const output = 'wrote src/index.ts\nmodified src/index.ts';
|
|
96
|
-
const files = extractModifiedFiles(output);
|
|
97
|
-
expect(files.filter(f => f === 'src/index.ts')).toHaveLength(1);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('returns empty array when no files found', () => {
|
|
101
|
-
expect(extractModifiedFiles('no files mentioned here')).toEqual([]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('extracts multiple different files', () => {
|
|
105
|
-
const output = 'wrote src/a.ts\ncreated src/b.ts\nedited src/c.ts';
|
|
106
|
-
const files = extractModifiedFiles(output);
|
|
107
|
-
expect(files).toContain('src/a.ts');
|
|
108
|
-
expect(files).toContain('src/b.ts');
|
|
109
|
-
expect(files).toContain('src/c.ts');
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ========== detectErrorInStderr ==========
|
|
114
|
-
|
|
115
|
-
describe('detectErrorInStderr', () => {
|
|
116
|
-
it('detects auth errors', () => {
|
|
117
|
-
const result = detectErrorInStderr('Error: not logged in to Claude');
|
|
118
|
-
expect(result).not.toBeNull();
|
|
119
|
-
expect(result!.errorCode).toBe('AUTH_REQUIRED');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('detects session expired', () => {
|
|
123
|
-
const result = detectErrorInStderr('Your session has expired, please re-authenticate');
|
|
124
|
-
expect(result).not.toBeNull();
|
|
125
|
-
expect(result!.errorCode).toBe('AUTH_REQUIRED');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('detects account not found', () => {
|
|
129
|
-
const result = detectErrorInStderr('account not found for this user');
|
|
130
|
-
expect(result).not.toBeNull();
|
|
131
|
-
expect(result!.errorCode).toBe('ACCOUNT_NOT_FOUND');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('detects API key errors', () => {
|
|
135
|
-
const result = detectErrorInStderr('invalid api key provided');
|
|
136
|
-
expect(result).not.toBeNull();
|
|
137
|
-
expect(result!.errorCode).toBe('API_KEY_INVALID');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('detects quota exceeded', () => {
|
|
141
|
-
const result = detectErrorInStderr('quota exceeded for your subscription');
|
|
142
|
-
expect(result).not.toBeNull();
|
|
143
|
-
expect(result!.errorCode).toBe('QUOTA_EXCEEDED');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('detects billing issues', () => {
|
|
147
|
-
const result = detectErrorInStderr('payment required to continue');
|
|
148
|
-
expect(result).not.toBeNull();
|
|
149
|
-
expect(result!.errorCode).toBe('QUOTA_EXCEEDED');
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('detects rate limiting', () => {
|
|
153
|
-
const result = detectErrorInStderr('rate limit exceeded, retry after 30s');
|
|
154
|
-
expect(result).not.toBeNull();
|
|
155
|
-
expect(result!.errorCode).toBe('RATE_LIMITED');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('detects 429 status', () => {
|
|
159
|
-
const result = detectErrorInStderr('HTTP 429 too many requests');
|
|
160
|
-
expect(result).not.toBeNull();
|
|
161
|
-
expect(result!.errorCode).toBe('RATE_LIMITED');
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('detects network errors', () => {
|
|
165
|
-
const result = detectErrorInStderr('ECONNREFUSED 127.0.0.1:443');
|
|
166
|
-
expect(result).not.toBeNull();
|
|
167
|
-
expect(result!.errorCode).toBe('NETWORK_ERROR');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('detects DNS failures', () => {
|
|
171
|
-
const result = detectErrorInStderr('ENOTFOUND api.anthropic.com');
|
|
172
|
-
expect(result).not.toBeNull();
|
|
173
|
-
expect(result!.errorCode).toBe('NETWORK_ERROR');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('detects SSL errors', () => {
|
|
177
|
-
const result = detectErrorInStderr('CERT_HAS_EXPIRED for api.example.com');
|
|
178
|
-
expect(result).not.toBeNull();
|
|
179
|
-
expect(result!.errorCode).toBe('SSL_ERROR');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('detects service unavailable', () => {
|
|
183
|
-
const result = detectErrorInStderr('service unavailable, try again later');
|
|
184
|
-
expect(result).not.toBeNull();
|
|
185
|
-
expect(result!.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('detects 503 status', () => {
|
|
189
|
-
const result = detectErrorInStderr('HTTP 503 from upstream');
|
|
190
|
-
expect(result).not.toBeNull();
|
|
191
|
-
expect(result!.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('detects internal errors', () => {
|
|
195
|
-
const result = detectErrorInStderr('internal server error occurred');
|
|
196
|
-
expect(result).not.toBeNull();
|
|
197
|
-
expect(result!.errorCode).toBe('INTERNAL_ERROR');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('detects context too long', () => {
|
|
201
|
-
const result = detectErrorInStderr('context too long, exceeds 200k tokens');
|
|
202
|
-
expect(result).not.toBeNull();
|
|
203
|
-
expect(result!.errorCode).toBe('CONTEXT_TOO_LONG');
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('detects session not found', () => {
|
|
207
|
-
const result = detectErrorInStderr('session not found, please create a new one');
|
|
208
|
-
expect(result).not.toBeNull();
|
|
209
|
-
expect(result!.errorCode).toBe('SESSION_NOT_FOUND');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('returns null for non-matching stderr', () => {
|
|
213
|
-
expect(detectErrorInStderr('Processing file...')).toBeNull();
|
|
214
|
-
expect(detectErrorInStderr('Warning: deprecated API usage')).toBeNull();
|
|
215
|
-
expect(detectErrorInStderr('')).toBeNull();
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('returns user-friendly messages', () => {
|
|
219
|
-
const result = detectErrorInStderr('not logged in');
|
|
220
|
-
expect(result).not.toBeNull();
|
|
221
|
-
expect(result!.message).toContain('authentication');
|
|
222
|
-
// Should not expose raw error
|
|
223
|
-
expect(result!.message).not.toContain('not logged in');
|
|
224
|
-
});
|
|
225
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { StallContext } from './stall-assessor.js';
|
|
3
|
-
|
|
4
|
-
// quickHeuristic, parseAssessmentResponse, and parseVerdictResponse are not exported.
|
|
5
|
-
// We test them via assessStall (which calls quickHeuristic first) and by testing
|
|
6
|
-
// the parsing functions indirectly. Since quickHeuristic is the critical logic
|
|
7
|
-
// and assessStall calls it before Haiku, we can test the heuristic paths by
|
|
8
|
-
// providing contexts that match known patterns.
|
|
9
|
-
//
|
|
10
|
-
// To avoid spawning Haiku (which requires `claude` CLI), we only test contexts
|
|
11
|
-
// that trigger the heuristic fast-path (return non-null from quickHeuristic).
|
|
12
|
-
|
|
13
|
-
import { assessStall } from './stall-assessor.js';
|
|
14
|
-
|
|
15
|
-
function makeContext(overrides: Partial<StallContext> = {}): StallContext {
|
|
16
|
-
return {
|
|
17
|
-
originalPrompt: 'Fix the bug in auth.ts',
|
|
18
|
-
silenceMs: 120_000,
|
|
19
|
-
pendingToolCount: 0,
|
|
20
|
-
totalToolCalls: 5,
|
|
21
|
-
elapsedTotalMs: 300_000,
|
|
22
|
-
...overrides,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
describe('assessStall - quickHeuristic paths', () => {
|
|
27
|
-
it('extends when tokens are still flowing (tokenSilenceMs < 60s)', async () => {
|
|
28
|
-
const ctx = makeContext({ tokenSilenceMs: 30_000 });
|
|
29
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
30
|
-
expect(verdict.action).toBe('extend');
|
|
31
|
-
expect(verdict.extensionMs).toBe(10 * 60_000);
|
|
32
|
-
expect(verdict.reason).toContain('Tokens still flowing');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('extends when tokenSilenceMs is 0', async () => {
|
|
36
|
-
const ctx = makeContext({ tokenSilenceMs: 0 });
|
|
37
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
38
|
-
expect(verdict.action).toBe('extend');
|
|
39
|
-
expect(verdict.reason).toContain('Tokens still flowing');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('does not use token heuristic when tokenSilenceMs >= 60s', async () => {
|
|
43
|
-
const ctx = makeContext({
|
|
44
|
-
tokenSilenceMs: 60_000,
|
|
45
|
-
pendingToolCount: 3, // will trigger parallel tools heuristic
|
|
46
|
-
});
|
|
47
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
48
|
-
// Should NOT hit the token heuristic, should hit the 3+ parallel tools one
|
|
49
|
-
expect(verdict.action).toBe('extend');
|
|
50
|
-
expect(verdict.reason).toContain('parallel tool calls');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('defers to watchdog when active and tools are pending', async () => {
|
|
54
|
-
const ctx = makeContext({ pendingToolCount: 1, lastToolName: 'Bash' });
|
|
55
|
-
const verdict = await assessStall(ctx, 'claude', false, true);
|
|
56
|
-
expect(verdict.action).toBe('extend');
|
|
57
|
-
expect(verdict.extensionMs).toBe(15 * 60_000);
|
|
58
|
-
expect(verdict.reason).toContain('Watchdog active');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('defers to watchdog and lists pending tool names', async () => {
|
|
62
|
-
const ctx = makeContext({
|
|
63
|
-
pendingToolCount: 2,
|
|
64
|
-
pendingToolNames: new Set(['WebFetch', 'Bash']),
|
|
65
|
-
});
|
|
66
|
-
const verdict = await assessStall(ctx, 'claude', false, true);
|
|
67
|
-
expect(verdict.action).toBe('extend');
|
|
68
|
-
expect(verdict.reason).toContain('WebFetch');
|
|
69
|
-
expect(verdict.reason).toContain('Bash');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('extends for Task subagent via pendingToolNames', async () => {
|
|
73
|
-
const ctx = makeContext({
|
|
74
|
-
pendingToolCount: 1,
|
|
75
|
-
pendingToolNames: new Set(['Task']),
|
|
76
|
-
});
|
|
77
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
78
|
-
expect(verdict.action).toBe('extend');
|
|
79
|
-
expect(verdict.reason).toContain('Task subagent');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('extends for Task subagent via lastToolName fallback', async () => {
|
|
83
|
-
const ctx = makeContext({
|
|
84
|
-
pendingToolCount: 1,
|
|
85
|
-
lastToolName: 'Task',
|
|
86
|
-
});
|
|
87
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
88
|
-
expect(verdict.action).toBe('extend');
|
|
89
|
-
expect(verdict.reason).toContain('Task subagent');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('scales Task extension with pending count', async () => {
|
|
93
|
-
const ctx1 = makeContext({
|
|
94
|
-
pendingToolCount: 1,
|
|
95
|
-
pendingToolNames: new Set(['Task']),
|
|
96
|
-
});
|
|
97
|
-
const ctx3 = makeContext({
|
|
98
|
-
pendingToolCount: 3,
|
|
99
|
-
pendingToolNames: new Set(['Task']),
|
|
100
|
-
});
|
|
101
|
-
const v1 = await assessStall(ctx1, 'claude', false, false);
|
|
102
|
-
const v3 = await assessStall(ctx3, 'claude', false, false);
|
|
103
|
-
// More pending = more extension, capped at 30 min
|
|
104
|
-
expect(v3.extensionMs).toBeGreaterThanOrEqual(v1.extensionMs);
|
|
105
|
-
expect(v3.extensionMs).toBeLessThanOrEqual(30 * 60_000);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('extends for 3+ parallel tool calls', async () => {
|
|
109
|
-
const ctx = makeContext({ pendingToolCount: 3 });
|
|
110
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
111
|
-
expect(verdict.action).toBe('extend');
|
|
112
|
-
expect(verdict.extensionMs).toBe(15 * 60_000);
|
|
113
|
-
expect(verdict.reason).toContain('parallel tool calls');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('extends for 5 parallel tool calls', async () => {
|
|
117
|
-
const ctx = makeContext({ pendingToolCount: 5 });
|
|
118
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
119
|
-
expect(verdict.action).toBe('extend');
|
|
120
|
-
expect(verdict.reason).toContain('5 parallel tool calls');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('extends for WebSearch without watchdog', async () => {
|
|
124
|
-
const ctx = makeContext({ lastToolName: 'WebSearch', pendingToolCount: 1 });
|
|
125
|
-
// pendingToolCount < 3, not Task, not watchdog active, but WebSearch
|
|
126
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
127
|
-
expect(verdict.action).toBe('extend');
|
|
128
|
-
expect(verdict.extensionMs).toBe(5 * 60_000);
|
|
129
|
-
expect(verdict.reason).toContain('WebSearch');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('extends for WebFetch without watchdog', async () => {
|
|
133
|
-
const ctx = makeContext({ lastToolName: 'WebFetch', pendingToolCount: 1 });
|
|
134
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
135
|
-
expect(verdict.action).toBe('extend');
|
|
136
|
-
expect(verdict.extensionMs).toBe(5 * 60_000);
|
|
137
|
-
expect(verdict.reason).toContain('WebFetch');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('does NOT extend for WebSearch when watchdog is active', async () => {
|
|
141
|
-
// When watchdog is active and tools are pending, the watchdog deferral
|
|
142
|
-
// takes priority over the WebSearch heuristic
|
|
143
|
-
const ctx = makeContext({
|
|
144
|
-
lastToolName: 'WebSearch',
|
|
145
|
-
pendingToolCount: 1,
|
|
146
|
-
});
|
|
147
|
-
const verdict = await assessStall(ctx, 'claude', false, true);
|
|
148
|
-
// Should defer to watchdog, not WebSearch heuristic
|
|
149
|
-
expect(verdict.action).toBe('extend');
|
|
150
|
-
expect(verdict.reason).toContain('Watchdog active');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('falls back to extend when Haiku assessment fails', async () => {
|
|
154
|
-
// Context that doesn't match any heuristic → triggers Haiku →
|
|
155
|
-
// Haiku fails (no `claude` binary) → cautious extend
|
|
156
|
-
const ctx = makeContext({
|
|
157
|
-
pendingToolCount: 1,
|
|
158
|
-
lastToolName: 'Edit',
|
|
159
|
-
});
|
|
160
|
-
const verdict = await assessStall(ctx, 'nonexistent-claude-binary', false, false);
|
|
161
|
-
expect(verdict.action).toBe('extend');
|
|
162
|
-
expect(verdict.extensionMs).toBe(10 * 60_000);
|
|
163
|
-
expect(verdict.reason).toContain('unavailable');
|
|
164
|
-
});
|
|
165
|
-
});
|