mstro-app 0.1.58 → 0.3.0
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/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +85 -42
- package/bin/commands/logout.js +35 -1
- package/bin/commands/status.js +1 -1
- package/bin/mstro.js +231 -131
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +550 -115
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +2 -1
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
- package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
- package/dist/server/cli/headless/prompt-utils.js +40 -5
- package/dist/server/cli/headless/prompt-utils.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +52 -7
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +79 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +355 -20
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +70 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
- package/dist/server/cli/headless/tool-watchdog.js +302 -0
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +98 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +136 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +929 -132
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +5 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +18 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +2 -2
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js +12 -8
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/security-patterns.d.ts.map +1 -1
- package/dist/server/mcp/security-patterns.js +9 -4
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/routes/improvise.js +6 -6
- package/dist/server/routes/improvise.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -0
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +26 -4
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +17 -10
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sandbox-utils.d.ts +6 -0
- package/dist/server/services/sandbox-utils.d.ts.map +1 -0
- package/dist/server/services/sandbox-utils.js +72 -0
- package/dist/server/services/sandbox-utils.js.map +1 -0
- package/dist/server/services/settings.d.ts +6 -0
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +21 -0
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +5 -51
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +63 -102
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -338
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +74 -2106
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +67 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/hooks/bouncer.sh +11 -4
- package/package.json +7 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +740 -133
- package/server/cli/headless/index.ts +7 -1
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/prompt-utils.ts +37 -5
- package/server/cli/headless/runner.ts +55 -8
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +478 -22
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +398 -0
- package/server/cli/headless/types.ts +93 -1
- package/server/cli/improvisation-session-manager.ts +1133 -145
- package/server/index.ts +5 -14
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/bouncer-integration.ts +28 -0
- package/server/mcp/security-audit.ts +12 -8
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/mcp/security-patterns.ts +8 -2
- package/server/routes/improvise.ts +6 -6
- package/server/services/analytics.ts +26 -4
- package/server/services/platform.test.ts +0 -10
- package/server/services/platform.ts +16 -11
- package/server/services/sandbox-utils.ts +78 -0
- package/server/services/settings.ts +25 -0
- package/server/services/terminal/pty-manager.ts +68 -129
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +90 -2421
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +145 -4
- package/bin/release.sh +0 -110
- package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
- package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
- package/dist/server/services/terminal/tmux-manager.js +0 -352
- package/dist/server/services/terminal/tmux-manager.js.map +0 -1
- package/server/services/terminal/tmux-manager.ts +0 -426
|
@@ -12,15 +12,13 @@
|
|
|
12
12
|
* - Scrollback buffer is maintained for replay on reconnect
|
|
13
13
|
* - Sessions can be reattached without losing running processes
|
|
14
14
|
*
|
|
15
|
-
* Also supports tmux-backed persistence for sessions that survive server restarts.
|
|
16
|
-
*
|
|
17
15
|
* NOTE: node-pty is an optional dependency requiring native compilation.
|
|
18
16
|
* Terminal features gracefully degrade when node-pty is not available.
|
|
19
17
|
*/
|
|
20
18
|
|
|
21
19
|
import { EventEmitter } from 'node:events';
|
|
22
20
|
import { homedir, platform } from 'node:os';
|
|
23
|
-
import {
|
|
21
|
+
import { sanitizeEnvForSandbox } from '../sandbox-utils.js';
|
|
24
22
|
|
|
25
23
|
// Try to load node-pty (optional native dependency)
|
|
26
24
|
let pty: typeof import('node-pty') | null = null;
|
|
@@ -74,11 +72,6 @@ export function getPtyInstallInstructions(): string {
|
|
|
74
72
|
return instructions;
|
|
75
73
|
}
|
|
76
74
|
|
|
77
|
-
// Maximum lines to store in scrollback buffer per terminal
|
|
78
|
-
const MAX_SCROLLBACK_LINES = 5000;
|
|
79
|
-
// Maximum characters per line to prevent memory bloat
|
|
80
|
-
const MAX_LINE_LENGTH = 2000;
|
|
81
|
-
|
|
82
75
|
// Import type separately for type-checking (doesn't require the module to load)
|
|
83
76
|
type IPty = import('node-pty').IPty;
|
|
84
77
|
|
|
@@ -87,8 +80,6 @@ export interface PTYSession {
|
|
|
87
80
|
pty: IPty;
|
|
88
81
|
shell: string;
|
|
89
82
|
cwd: string;
|
|
90
|
-
// Scrollback buffer for replay on reconnect
|
|
91
|
-
scrollback: string[];
|
|
92
83
|
// Timestamp when session was created
|
|
93
84
|
createdAt: number;
|
|
94
85
|
// Last activity timestamp
|
|
@@ -96,6 +87,9 @@ export interface PTYSession {
|
|
|
96
87
|
// Current dimensions
|
|
97
88
|
cols: number;
|
|
98
89
|
rows: number;
|
|
90
|
+
// Output coalescing: buffer small chunks into fewer WS messages
|
|
91
|
+
_outputBuffer: string;
|
|
92
|
+
_outputTimer: ReturnType<typeof setTimeout> | null;
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
/**
|
|
@@ -156,41 +150,6 @@ export class PTYManager extends EventEmitter {
|
|
|
156
150
|
};
|
|
157
151
|
}
|
|
158
152
|
|
|
159
|
-
/**
|
|
160
|
-
* Get scrollback buffer for replay on reconnect
|
|
161
|
-
* Returns the stored output history
|
|
162
|
-
*/
|
|
163
|
-
getScrollback(terminalId: string): string[] {
|
|
164
|
-
const session = this.terminals.get(terminalId);
|
|
165
|
-
if (!session) return [];
|
|
166
|
-
return [...session.scrollback];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Add data to scrollback buffer
|
|
171
|
-
* Maintains a rolling buffer of recent terminal output
|
|
172
|
-
*/
|
|
173
|
-
private addToScrollback(session: PTYSession, data: string): void {
|
|
174
|
-
// Split data into lines
|
|
175
|
-
const lines = data.split(/\r?\n/);
|
|
176
|
-
|
|
177
|
-
for (const line of lines) {
|
|
178
|
-
// Truncate very long lines to prevent memory issues
|
|
179
|
-
const truncatedLine = line.length > MAX_LINE_LENGTH
|
|
180
|
-
? `${line.slice(0, MAX_LINE_LENGTH)}...`
|
|
181
|
-
: line;
|
|
182
|
-
|
|
183
|
-
session.scrollback.push(truncatedLine);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Trim buffer if it exceeds max size
|
|
187
|
-
if (session.scrollback.length > MAX_SCROLLBACK_LINES) {
|
|
188
|
-
session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_LINES);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
session.lastActivityAt = Date.now();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
153
|
/**
|
|
195
154
|
* Check if PTY functionality is available
|
|
196
155
|
*/
|
|
@@ -213,7 +172,8 @@ export class PTYManager extends EventEmitter {
|
|
|
213
172
|
workingDir: string,
|
|
214
173
|
cols: number = 80,
|
|
215
174
|
rows: number = 24,
|
|
216
|
-
requestedShell?: string
|
|
175
|
+
requestedShell?: string,
|
|
176
|
+
options?: { sandboxed?: boolean }
|
|
217
177
|
): { shell: string; cwd: string; isReconnect: boolean } {
|
|
218
178
|
// Check if node-pty is available
|
|
219
179
|
if (!pty) {
|
|
@@ -242,43 +202,83 @@ export class PTYManager extends EventEmitter {
|
|
|
242
202
|
|
|
243
203
|
|
|
244
204
|
try {
|
|
205
|
+
// Build env: sandboxed sessions get stripped secrets and HOME=projectDir
|
|
206
|
+
const baseEnv = options?.sandboxed
|
|
207
|
+
? sanitizeEnvForSandbox(process.env, cwd)
|
|
208
|
+
: { ...process.env, HOME: homedir() };
|
|
209
|
+
const env = {
|
|
210
|
+
...baseEnv,
|
|
211
|
+
TERM: 'xterm-256color',
|
|
212
|
+
COLORTERM: 'truecolor',
|
|
213
|
+
};
|
|
214
|
+
|
|
245
215
|
// Spawn the PTY process
|
|
246
216
|
const ptyProcess = pty.spawn(shell, [], {
|
|
247
217
|
name: 'xterm-256color',
|
|
248
218
|
cols,
|
|
249
219
|
rows,
|
|
250
220
|
cwd,
|
|
251
|
-
env
|
|
252
|
-
...process.env,
|
|
253
|
-
TERM: 'xterm-256color',
|
|
254
|
-
COLORTERM: 'truecolor',
|
|
255
|
-
// Ensure home directory is set
|
|
256
|
-
HOME: homedir(),
|
|
257
|
-
},
|
|
221
|
+
env,
|
|
258
222
|
});
|
|
259
223
|
|
|
260
|
-
// Store the session with scrollback buffer
|
|
261
224
|
const session: PTYSession = {
|
|
262
225
|
id: terminalId,
|
|
263
226
|
pty: ptyProcess,
|
|
264
227
|
shell: getShellName(shell),
|
|
265
228
|
cwd,
|
|
266
|
-
scrollback: [],
|
|
267
229
|
createdAt: Date.now(),
|
|
268
230
|
lastActivityAt: Date.now(),
|
|
269
231
|
cols,
|
|
270
232
|
rows,
|
|
233
|
+
_outputBuffer: '',
|
|
234
|
+
_outputTimer: null,
|
|
271
235
|
};
|
|
272
236
|
this.terminals.set(terminalId, session);
|
|
273
237
|
|
|
274
|
-
// Handle data output
|
|
238
|
+
// Handle data output — coalesce small chunks to reduce WebSocket message count.
|
|
239
|
+
// On macOS, node-pty emits many tiny chunks (sometimes single bytes) and zsh
|
|
240
|
+
// wraps echoed chars in multi-part ANSI sequences (RPROMPT, syntax highlighting).
|
|
241
|
+
// A longer window on macOS ensures these multi-part sequences arrive as one chunk,
|
|
242
|
+
// which the browser's predictive echo can match correctly.
|
|
243
|
+
const OUTPUT_COALESCE_MS = platform() === 'darwin' ? 16 : 8;
|
|
244
|
+
// High-water mark: flush immediately when buffer exceeds this size
|
|
245
|
+
// to prevent unbounded memory growth during high-output commands (e.g. `yes`)
|
|
246
|
+
const OUTPUT_HIGH_WATER = 64 * 1024; // 64KB
|
|
247
|
+
// Maximum chunk size per WebSocket message to prevent browser overload
|
|
248
|
+
const OUTPUT_CHUNK_SIZE = 64 * 1024;
|
|
249
|
+
|
|
250
|
+
const flushOutputBuffer = () => {
|
|
251
|
+
if (session._outputTimer) {
|
|
252
|
+
clearTimeout(session._outputTimer);
|
|
253
|
+
session._outputTimer = null;
|
|
254
|
+
}
|
|
255
|
+
const buffered = session._outputBuffer;
|
|
256
|
+
session._outputBuffer = '';
|
|
257
|
+
// Chunk large output to prevent single massive WebSocket frames
|
|
258
|
+
for (let i = 0; i < buffered.length; i += OUTPUT_CHUNK_SIZE) {
|
|
259
|
+
this.emit('output', terminalId, buffered.slice(i, i + OUTPUT_CHUNK_SIZE));
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
275
263
|
ptyProcess.onData((data: string) => {
|
|
276
|
-
|
|
277
|
-
|
|
264
|
+
session.lastActivityAt = Date.now();
|
|
265
|
+
session._outputBuffer += data;
|
|
266
|
+
// Flush immediately if buffer exceeds high-water mark
|
|
267
|
+
if (session._outputBuffer.length >= OUTPUT_HIGH_WATER) {
|
|
268
|
+
flushOutputBuffer();
|
|
269
|
+
} else if (!session._outputTimer) {
|
|
270
|
+
session._outputTimer = setTimeout(flushOutputBuffer, OUTPUT_COALESCE_MS);
|
|
271
|
+
}
|
|
278
272
|
});
|
|
279
273
|
|
|
280
|
-
// Handle exit
|
|
274
|
+
// Handle exit — flush any buffered output first
|
|
281
275
|
ptyProcess.onExit(({ exitCode }) => {
|
|
276
|
+
if (session._outputBuffer) {
|
|
277
|
+
flushOutputBuffer();
|
|
278
|
+
} else if (session._outputTimer) {
|
|
279
|
+
clearTimeout(session._outputTimer);
|
|
280
|
+
session._outputTimer = null;
|
|
281
|
+
}
|
|
282
282
|
this.emit('exit', terminalId, exitCode);
|
|
283
283
|
this.terminals.delete(terminalId);
|
|
284
284
|
});
|
|
@@ -341,6 +341,15 @@ export class PTYManager extends EventEmitter {
|
|
|
341
341
|
|
|
342
342
|
|
|
343
343
|
try {
|
|
344
|
+
// Flush any coalesced output before closing
|
|
345
|
+
if (session._outputTimer) {
|
|
346
|
+
clearTimeout(session._outputTimer);
|
|
347
|
+
if (session._outputBuffer) {
|
|
348
|
+
this.emit('output', terminalId, session._outputBuffer);
|
|
349
|
+
session._outputBuffer = '';
|
|
350
|
+
}
|
|
351
|
+
session._outputTimer = null;
|
|
352
|
+
}
|
|
344
353
|
session.pty.kill();
|
|
345
354
|
this.terminals.delete(terminalId);
|
|
346
355
|
return true;
|
|
@@ -381,76 +390,6 @@ export class PTYManager extends EventEmitter {
|
|
|
381
390
|
}
|
|
382
391
|
}
|
|
383
392
|
|
|
384
|
-
/**
|
|
385
|
-
* Check if tmux persistence is available
|
|
386
|
-
*/
|
|
387
|
-
isTmuxAvailable(): boolean {
|
|
388
|
-
return isTmuxAvailable();
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Get list of persistent tmux sessions that can be restored
|
|
393
|
-
* These are sessions that survived a server restart
|
|
394
|
-
*/
|
|
395
|
-
getPersistentSessions(): TmuxSession[] {
|
|
396
|
-
const tmux = getTmuxManager();
|
|
397
|
-
return tmux.getActiveSessions();
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Create a persistent (tmux-backed) terminal session
|
|
402
|
-
* These sessions survive server restarts
|
|
403
|
-
*/
|
|
404
|
-
createPersistent(
|
|
405
|
-
terminalId: string,
|
|
406
|
-
workingDir: string,
|
|
407
|
-
cols: number = 80,
|
|
408
|
-
rows: number = 24,
|
|
409
|
-
requestedShell?: string
|
|
410
|
-
): { shell: string; cwd: string; isReconnect: boolean; persistent: true } {
|
|
411
|
-
const tmux = getTmuxManager();
|
|
412
|
-
|
|
413
|
-
if (!tmux.isAvailable()) {
|
|
414
|
-
throw new Error('tmux is not available for persistent sessions');
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const result = tmux.create(terminalId, workingDir, cols, rows, requestedShell);
|
|
418
|
-
return { ...result, persistent: true };
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Attach to a persistent (tmux) session
|
|
423
|
-
* Returns handlers for write, resize, and detach
|
|
424
|
-
*/
|
|
425
|
-
attachPersistent(
|
|
426
|
-
terminalId: string,
|
|
427
|
-
onOutput: (data: string) => void,
|
|
428
|
-
onExit: (code: number) => void
|
|
429
|
-
): { write: (data: string) => void; resize: (cols: number, rows: number) => void; detach: () => void } | null {
|
|
430
|
-
const tmux = getTmuxManager();
|
|
431
|
-
|
|
432
|
-
if (!tmux.exists(terminalId)) {
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
return tmux.attach(terminalId, onOutput, onExit);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Get scrollback from a persistent (tmux) session
|
|
441
|
-
*/
|
|
442
|
-
getPersistentScrollback(terminalId: string): string[] {
|
|
443
|
-
const tmux = getTmuxManager();
|
|
444
|
-
return tmux.getScrollback(terminalId);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Close a persistent (tmux) session
|
|
449
|
-
*/
|
|
450
|
-
closePersistent(terminalId: string): boolean {
|
|
451
|
-
const tmux = getTmuxManager();
|
|
452
|
-
return tmux.close(terminalId);
|
|
453
|
-
}
|
|
454
393
|
}
|
|
455
394
|
|
|
456
395
|
// Singleton instance
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AutocompleteService } from './autocomplete.js';
|
|
3
|
+
|
|
4
|
+
// Mock file system operations to avoid hitting real FS
|
|
5
|
+
vi.mock('node:fs', () => ({
|
|
6
|
+
existsSync: vi.fn(() => false),
|
|
7
|
+
readdirSync: vi.fn(() => []),
|
|
8
|
+
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('./file-utils.js', () => ({
|
|
12
|
+
CACHE_TTL_MS: 5000,
|
|
13
|
+
directoryCache: new Map(),
|
|
14
|
+
getFileType: vi.fn((path: string) => {
|
|
15
|
+
const ext = path.split('.').pop() || '';
|
|
16
|
+
return ext || 'unknown';
|
|
17
|
+
}),
|
|
18
|
+
isIgnored: vi.fn(() => false),
|
|
19
|
+
parseGitignore: vi.fn(() => []),
|
|
20
|
+
scanDirectoryRecursiveWithDepth: vi.fn(() => []),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('AutocompleteService', () => {
|
|
24
|
+
// ========== Frecency ==========
|
|
25
|
+
|
|
26
|
+
describe('calculateFrecencyScore', () => {
|
|
27
|
+
it('returns 0 for unknown files', () => {
|
|
28
|
+
const svc = new AutocompleteService();
|
|
29
|
+
expect(svc.calculateFrecencyScore('nonexistent.ts')).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns positive score for recently used files', () => {
|
|
33
|
+
const svc = new AutocompleteService();
|
|
34
|
+
svc.recordFileSelection('src/index.ts');
|
|
35
|
+
const score = svc.calculateFrecencyScore('src/index.ts');
|
|
36
|
+
expect(score).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('increases score with more selections', () => {
|
|
40
|
+
const svc = new AutocompleteService();
|
|
41
|
+
svc.recordFileSelection('src/index.ts');
|
|
42
|
+
const score1 = svc.calculateFrecencyScore('src/index.ts');
|
|
43
|
+
|
|
44
|
+
svc.recordFileSelection('src/index.ts');
|
|
45
|
+
svc.recordFileSelection('src/index.ts');
|
|
46
|
+
const score3 = svc.calculateFrecencyScore('src/index.ts');
|
|
47
|
+
|
|
48
|
+
expect(score3).toBeGreaterThan(score1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('decays score over time', () => {
|
|
52
|
+
const svc = new AutocompleteService();
|
|
53
|
+
|
|
54
|
+
// Record selection at a specific time
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
57
|
+
svc.recordFileSelection('src/index.ts');
|
|
58
|
+
const recentScore = svc.calculateFrecencyScore('src/index.ts');
|
|
59
|
+
|
|
60
|
+
// Move forward 8 days (past the 7-day recency window)
|
|
61
|
+
vi.spyOn(Date, 'now').mockReturnValue(now + 8 * 24 * 60 * 60 * 1000);
|
|
62
|
+
const staleScore = svc.calculateFrecencyScore('src/index.ts');
|
|
63
|
+
|
|
64
|
+
expect(staleScore).toBeLessThan(recentScore);
|
|
65
|
+
vi.restoreAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles initial frecency data in constructor', () => {
|
|
69
|
+
const svc = new AutocompleteService({
|
|
70
|
+
'src/main.ts': { count: 5, lastUsed: Date.now() },
|
|
71
|
+
});
|
|
72
|
+
expect(svc.calculateFrecencyScore('src/main.ts')).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ========== recordFileSelection ==========
|
|
77
|
+
|
|
78
|
+
describe('recordFileSelection', () => {
|
|
79
|
+
it('creates new entry for first selection', () => {
|
|
80
|
+
const svc = new AutocompleteService();
|
|
81
|
+
svc.recordFileSelection('new-file.ts');
|
|
82
|
+
|
|
83
|
+
const data = svc.getFrecencyData();
|
|
84
|
+
expect(data['new-file.ts']).toBeDefined();
|
|
85
|
+
expect(data['new-file.ts'].count).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('increments count for existing entry', () => {
|
|
89
|
+
const svc = new AutocompleteService();
|
|
90
|
+
svc.recordFileSelection('file.ts');
|
|
91
|
+
svc.recordFileSelection('file.ts');
|
|
92
|
+
svc.recordFileSelection('file.ts');
|
|
93
|
+
|
|
94
|
+
const data = svc.getFrecencyData();
|
|
95
|
+
expect(data['file.ts'].count).toBe(3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('updates lastUsed timestamp', () => {
|
|
99
|
+
const svc = new AutocompleteService();
|
|
100
|
+
const before = Date.now();
|
|
101
|
+
svc.recordFileSelection('file.ts');
|
|
102
|
+
const data = svc.getFrecencyData();
|
|
103
|
+
expect(data['file.ts'].lastUsed).toBeGreaterThanOrEqual(before);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ========== setFrecencyData / getFrecencyData ==========
|
|
108
|
+
|
|
109
|
+
describe('setFrecencyData / getFrecencyData', () => {
|
|
110
|
+
it('replaces frecency data', () => {
|
|
111
|
+
const svc = new AutocompleteService();
|
|
112
|
+
svc.recordFileSelection('old.ts');
|
|
113
|
+
|
|
114
|
+
const newData = {
|
|
115
|
+
'new.ts': { count: 10, lastUsed: Date.now() },
|
|
116
|
+
};
|
|
117
|
+
svc.setFrecencyData(newData);
|
|
118
|
+
|
|
119
|
+
expect(svc.getFrecencyData()).toBe(newData);
|
|
120
|
+
expect(svc.calculateFrecencyScore('old.ts')).toBe(0);
|
|
121
|
+
expect(svc.calculateFrecencyScore('new.ts')).toBeGreaterThan(0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ========== getFileCompletions ==========
|
|
126
|
+
|
|
127
|
+
describe('getFileCompletions', () => {
|
|
128
|
+
it('returns empty array when no files match', () => {
|
|
129
|
+
const svc = new AutocompleteService();
|
|
130
|
+
const results = svc.getFileCompletions('nonexistent', '/tmp/test');
|
|
131
|
+
expect(results).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('handles @ symbol prefix', () => {
|
|
135
|
+
const svc = new AutocompleteService();
|
|
136
|
+
// Should not throw when handling @ prefix
|
|
137
|
+
const results = svc.getFileCompletions('@src/index', '/tmp/test');
|
|
138
|
+
expect(Array.isArray(results)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns empty array on error', () => {
|
|
142
|
+
const svc = new AutocompleteService();
|
|
143
|
+
// Invalid working dir should return empty (caught by try/catch)
|
|
144
|
+
const results = svc.getFileCompletions('test', '/nonexistent/path');
|
|
145
|
+
expect(results).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('limits results to 15', () => {
|
|
149
|
+
// This is tested structurally — the code slices to 15
|
|
150
|
+
const svc = new AutocompleteService();
|
|
151
|
+
const results = svc.getFileCompletions('', '/tmp/test');
|
|
152
|
+
expect(results.length).toBeLessThanOrEqual(15);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ========== Scoring logic (frecency weight formula) ==========
|
|
157
|
+
|
|
158
|
+
describe('frecency scoring formula', () => {
|
|
159
|
+
it('uses log2 for frequency weight', () => {
|
|
160
|
+
const svc = new AutocompleteService();
|
|
161
|
+
|
|
162
|
+
// count=1: log2(2) = 1
|
|
163
|
+
svc.setFrecencyData({ 'a.ts': { count: 1, lastUsed: Date.now() } });
|
|
164
|
+
const score1 = svc.calculateFrecencyScore('a.ts');
|
|
165
|
+
|
|
166
|
+
// count=7: log2(8) = 3
|
|
167
|
+
svc.setFrecencyData({ 'a.ts': { count: 7, lastUsed: Date.now() } });
|
|
168
|
+
const score7 = svc.calculateFrecencyScore('a.ts');
|
|
169
|
+
|
|
170
|
+
// Score should roughly triple (3x) since frequency goes from 1 to 3
|
|
171
|
+
expect(score7 / score1).toBeCloseTo(3, 0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('recency weight is ~1.0 for very recent files', () => {
|
|
175
|
+
const svc = new AutocompleteService();
|
|
176
|
+
svc.setFrecencyData({ 'a.ts': { count: 1, lastUsed: Date.now() } });
|
|
177
|
+
const score = svc.calculateFrecencyScore('a.ts');
|
|
178
|
+
|
|
179
|
+
// With recencyWeight ≈ 1, score ≈ log2(2) * (0.3 + 0.7*1) * 100 = 100
|
|
180
|
+
expect(score).toBeCloseTo(100, -1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('recency weight is ~0.3 for files used > 7 days ago', () => {
|
|
184
|
+
const svc = new AutocompleteService();
|
|
185
|
+
const eightDaysAgo = Date.now() - (8 * 24 * 60 * 60 * 1000);
|
|
186
|
+
svc.setFrecencyData({ 'a.ts': { count: 1, lastUsed: eightDaysAgo } });
|
|
187
|
+
const score = svc.calculateFrecencyScore('a.ts');
|
|
188
|
+
|
|
189
|
+
// With recencyWeight = max(0, 1 - 8*24/168) = 0
|
|
190
|
+
// score ≈ log2(2) * (0.3 + 0.7*0) * 100 = 30
|
|
191
|
+
expect(score).toBeCloseTo(30, -1);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|