auq-mcp-server 0.1.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/LICENSE +25 -0
- package/README.md +176 -0
- package/dist/__tests__/schema-validation.test.js +137 -0
- package/dist/__tests__/server.integration.test.js +263 -0
- package/dist/add.js +1 -0
- package/dist/add.test.js +5 -0
- package/dist/bin/auq.js +245 -0
- package/dist/bin/test-session-menu.js +28 -0
- package/dist/bin/test-tabbar.js +42 -0
- package/dist/file-utils.js +59 -0
- package/dist/format/ResponseFormatter.js +206 -0
- package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
- package/dist/package.json +74 -0
- package/dist/server.js +107 -0
- package/dist/session/ResponseFormatter.js +130 -0
- package/dist/session/SessionManager.js +474 -0
- package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
- package/dist/session/__tests__/SessionManager.test.js +553 -0
- package/dist/session/__tests__/atomic-operations.test.js +345 -0
- package/dist/session/__tests__/file-watcher.test.js +311 -0
- package/dist/session/__tests__/workflow.integration.test.js +334 -0
- package/dist/session/atomic-operations.js +307 -0
- package/dist/session/file-watcher.js +218 -0
- package/dist/session/index.js +7 -0
- package/dist/session/types.js +20 -0
- package/dist/session/utils.js +125 -0
- package/dist/session-manager.js +171 -0
- package/dist/session-watcher.js +110 -0
- package/dist/src/__tests__/schema-validation.test.js +170 -0
- package/dist/src/__tests__/server.integration.test.js +274 -0
- package/dist/src/add.js +1 -0
- package/dist/src/add.test.js +5 -0
- package/dist/src/server.js +163 -0
- package/dist/src/session/ResponseFormatter.js +163 -0
- package/dist/src/session/SessionManager.js +572 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
- package/dist/src/session/__tests__/SessionManager.test.js +593 -0
- package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
- package/dist/src/session/__tests__/file-watcher.test.js +311 -0
- package/dist/src/session/atomic-operations.js +307 -0
- package/dist/src/session/file-watcher.js +227 -0
- package/dist/src/session/index.js +7 -0
- package/dist/src/session/types.js +20 -0
- package/dist/src/session/utils.js +180 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/src/tui/components/AnimatedGradient.js +45 -0
- package/dist/src/tui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui/components/CustomInput.js +14 -0
- package/dist/src/tui/components/Footer.js +55 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/MultiLineTextInput.js +65 -0
- package/dist/src/tui/components/OptionsList.js +115 -0
- package/dist/src/tui/components/QuestionDisplay.js +36 -0
- package/dist/src/tui/components/ReviewScreen.js +57 -0
- package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
- package/dist/src/tui/components/StepperView.js +166 -0
- package/dist/src/tui/components/TabBar.js +42 -0
- package/dist/src/tui/components/Toast.js +19 -0
- package/dist/src/tui/components/WaitingScreen.js +20 -0
- package/dist/src/tui/session-watcher.js +195 -0
- package/dist/src/tui/theme.js +114 -0
- package/dist/src/tui/utils/gradientText.js +24 -0
- package/dist/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/tui/session-watcher.js +183 -0
- package/package.json +78 -0
- package/scripts/postinstall.cjs +51 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file operations for session management
|
|
3
|
+
* Provides truly atomic read/write operations to prevent data corruption
|
|
4
|
+
*/
|
|
5
|
+
import { constants, copyFile, rename } from "fs";
|
|
6
|
+
import { promises as fs } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { fileExists } from "./utils.js";
|
|
10
|
+
/**
|
|
11
|
+
* Error types for atomic operations
|
|
12
|
+
*/
|
|
13
|
+
export class AtomicOperationError extends Error {
|
|
14
|
+
operation;
|
|
15
|
+
filePath;
|
|
16
|
+
cause;
|
|
17
|
+
constructor(message, operation, filePath, cause) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.operation = operation;
|
|
20
|
+
this.filePath = filePath;
|
|
21
|
+
this.cause = cause;
|
|
22
|
+
this.name = "AtomicOperationError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class AtomicReadError extends AtomicOperationError {
|
|
26
|
+
constructor(filePath, cause) {
|
|
27
|
+
super(`Atomic read failed: ${filePath}`, "read", filePath, cause);
|
|
28
|
+
this.name = "AtomicReadError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class AtomicWriteError extends AtomicOperationError {
|
|
32
|
+
constructor(filePath, cause) {
|
|
33
|
+
super(`Atomic write failed: ${filePath}`, "write", filePath, cause);
|
|
34
|
+
this.name = "AtomicWriteError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class FileLockError extends AtomicOperationError {
|
|
38
|
+
constructor(filePath, cause) {
|
|
39
|
+
super(`Failed to acquire file lock: ${filePath}`, "write", filePath, cause);
|
|
40
|
+
this.name = "FileLockError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const DEFAULT_WRITE_OPTIONS = {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
flag: "w",
|
|
46
|
+
maxRetries: 3,
|
|
47
|
+
mode: 0o600,
|
|
48
|
+
retryDelay: 100,
|
|
49
|
+
tmpDir: tmpdir(),
|
|
50
|
+
};
|
|
51
|
+
const DEFAULT_READ_OPTIONS = {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
flag: "r",
|
|
54
|
+
maxRetries: 3,
|
|
55
|
+
retryDelay: 100,
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Atomic file copy operation
|
|
59
|
+
*/
|
|
60
|
+
export async function atomicCopyFile(sourcePath, destPath, options = {}) {
|
|
61
|
+
const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
|
|
62
|
+
try {
|
|
63
|
+
// Acquire destination lock
|
|
64
|
+
await acquireLock(destPath);
|
|
65
|
+
// Perform atomic copy
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
copyFile(sourcePath, destPath, constants.COPYFILE_EXCL, (error) => {
|
|
68
|
+
if (error) {
|
|
69
|
+
reject(error);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
resolve();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
// Set correct permissions on destination
|
|
77
|
+
await fs.chmod(destPath, opts.mode);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new AtomicOperationError(`Atomic copy failed: ${sourcePath} -> ${destPath}`, "write", destPath, error);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await releaseLock(destPath);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Atomic file delete operation
|
|
88
|
+
*/
|
|
89
|
+
export async function atomicDeleteFile(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
await acquireLock(filePath);
|
|
92
|
+
await fs.unlink(filePath);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === "ENOENT") {
|
|
96
|
+
// File doesn't exist, that's okay for delete
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw new AtomicOperationError(`Atomic delete failed: ${filePath}`, "write", filePath, error);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await releaseLock(filePath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Atomic read operation with retry logic
|
|
107
|
+
*/
|
|
108
|
+
export async function atomicReadFile(filePath, options = {}) {
|
|
109
|
+
const opts = { ...DEFAULT_READ_OPTIONS, ...options };
|
|
110
|
+
let lastError = null;
|
|
111
|
+
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
// Check if file exists
|
|
114
|
+
if (!(await fileExists(filePath))) {
|
|
115
|
+
throw new AtomicReadError(filePath, new Error("File does not exist"));
|
|
116
|
+
}
|
|
117
|
+
// Acquire read lock (shared lock)
|
|
118
|
+
await acquireLock(filePath);
|
|
119
|
+
try {
|
|
120
|
+
const data = await fs.readFile(filePath, {
|
|
121
|
+
encoding: opts.encoding,
|
|
122
|
+
flag: opts.flag,
|
|
123
|
+
});
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await releaseLock(filePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
lastError = error;
|
|
132
|
+
// Don't retry on certain errors
|
|
133
|
+
if (error instanceof AtomicReadError ||
|
|
134
|
+
error.code === "ENOENT" ||
|
|
135
|
+
error.code === "EACCES") {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
// Wait before retrying
|
|
139
|
+
if (attempt < opts.maxRetries - 1) {
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, opts.retryDelay * Math.pow(2, attempt)));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw new AtomicReadError(filePath, lastError || new Error("Unknown error"));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Atomic write operation using temporary file and rename
|
|
148
|
+
*/
|
|
149
|
+
export async function atomicWriteFile(filePath, data, options = {}) {
|
|
150
|
+
const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
|
|
151
|
+
const tempPath = generateTempPath(filePath, opts.tmpDir);
|
|
152
|
+
try {
|
|
153
|
+
// Acquire file lock
|
|
154
|
+
await acquireLock(filePath);
|
|
155
|
+
// Write to temporary file first
|
|
156
|
+
await fs.writeFile(tempPath, data, {
|
|
157
|
+
encoding: opts.encoding,
|
|
158
|
+
flag: opts.flag,
|
|
159
|
+
mode: opts.mode,
|
|
160
|
+
});
|
|
161
|
+
// Verify the temporary file was written correctly
|
|
162
|
+
const verificationData = await fs.readFile(tempPath, opts.encoding);
|
|
163
|
+
if (verificationData !== data) {
|
|
164
|
+
throw new AtomicWriteError(filePath, new Error("Data verification failed after write"));
|
|
165
|
+
}
|
|
166
|
+
// Atomic rename operation
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
rename(tempPath, filePath, (error) => {
|
|
169
|
+
if (error) {
|
|
170
|
+
reject(error);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
resolve();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// Verify the final file exists and has correct permissions
|
|
178
|
+
try {
|
|
179
|
+
await fs.access(filePath, constants.F_OK);
|
|
180
|
+
const stats = await fs.stat(filePath);
|
|
181
|
+
// Check if mode is correct (at least read/write for owner)
|
|
182
|
+
if ((stats.mode & 0o600) !== 0o600) {
|
|
183
|
+
await fs.chmod(filePath, opts.mode);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
throw new AtomicWriteError(filePath, new Error(`File verification failed after rename: ${error}`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
// Clean up temporary file on failure
|
|
192
|
+
try {
|
|
193
|
+
await fs.unlink(tempPath);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Ignore cleanup errors
|
|
197
|
+
}
|
|
198
|
+
throw new AtomicWriteError(filePath, error);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
// Always release the lock
|
|
202
|
+
await releaseLock(filePath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Check if a file is locked
|
|
207
|
+
*/
|
|
208
|
+
export async function isFileLocked(filePath) {
|
|
209
|
+
const lockPath = `${filePath}.lock`;
|
|
210
|
+
return await fileExists(lockPath);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Wait for a file to become unlocked (with timeout)
|
|
214
|
+
*/
|
|
215
|
+
export async function waitForFileUnlock(filePath, timeout = 10000) {
|
|
216
|
+
const startTime = Date.now();
|
|
217
|
+
while (Date.now() - startTime < timeout) {
|
|
218
|
+
if (!(await isFileLocked(filePath))) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
222
|
+
}
|
|
223
|
+
throw new FileLockError(filePath, new Error("Timeout waiting for file unlock"));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Simple file lock implementation using lock files
|
|
227
|
+
*/
|
|
228
|
+
async function acquireLock(filePath, timeout = 5000) {
|
|
229
|
+
const lockPath = `${filePath}.lock`;
|
|
230
|
+
const startTime = Date.now();
|
|
231
|
+
while (Date.now() - startTime < timeout) {
|
|
232
|
+
try {
|
|
233
|
+
// Try to create lock file (O_EXCL ensures atomic creation)
|
|
234
|
+
await fs.writeFile(lockPath, process.pid.toString(), {
|
|
235
|
+
encoding: "utf8",
|
|
236
|
+
flag: "wx", // Write and fail if file exists
|
|
237
|
+
mode: 0o600,
|
|
238
|
+
});
|
|
239
|
+
return; // Lock acquired
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error.code === "EEXIST") {
|
|
243
|
+
// Lock file exists, check if it's stale
|
|
244
|
+
try {
|
|
245
|
+
const lockContent = await fs.readFile(lockPath, "utf8");
|
|
246
|
+
const lockPid = parseInt(lockContent.trim(), 10);
|
|
247
|
+
// Check if process with PID still exists
|
|
248
|
+
try {
|
|
249
|
+
process.kill(lockPid, 0); // Signal 0 just checks if process exists
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Process doesn't exist, remove stale lock
|
|
253
|
+
await fs.unlink(lockPath);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Can't read lock file, try to remove it
|
|
259
|
+
try {
|
|
260
|
+
await fs.unlink(lockPath);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Can't remove lock file, continue waiting
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Lock is active, wait and retry
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
throw error; // Other error occurred
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw new FileLockError(filePath, new Error("Lock acquisition timeout"));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Get the base filename from a path
|
|
278
|
+
*/
|
|
279
|
+
function basename(path) {
|
|
280
|
+
return path.split(/[\\/]/).pop() || "";
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Generate a unique temporary file path
|
|
284
|
+
*/
|
|
285
|
+
function generateTempPath(originalPath, tmpDir) {
|
|
286
|
+
const timestamp = Date.now();
|
|
287
|
+
const random = Math.random().toString(36).substring(2);
|
|
288
|
+
const filename = `${timestamp}-${random}-${basename(originalPath)}.tmp`;
|
|
289
|
+
return join(tmpDir, filename);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Release a file lock
|
|
293
|
+
*/
|
|
294
|
+
async function releaseLock(filePath) {
|
|
295
|
+
const lockPath = `${filePath}.lock`;
|
|
296
|
+
try {
|
|
297
|
+
await fs.unlink(lockPath);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// Lock file might not exist or we don't have permission
|
|
301
|
+
// Don't throw error as this is cleanup
|
|
302
|
+
// Only log if it's not ENOENT (file not found)
|
|
303
|
+
if (error.code !== "ENOENT") {
|
|
304
|
+
console.warn(`Warning: Could not release lock for ${filePath}:`, error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File System Watching Module
|
|
3
|
+
*
|
|
4
|
+
* Provides cross-platform file system watching capabilities for both
|
|
5
|
+
* MCP server and TUI coordination with proper debouncing and error handling.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from "events";
|
|
8
|
+
import { watch } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { fileExists } from "./utils.js";
|
|
11
|
+
import { resolveSessionDirectory } from "./utils.js";
|
|
12
|
+
/**
|
|
13
|
+
* Promise-based file watcher for specific file patterns
|
|
14
|
+
*/
|
|
15
|
+
export class PromiseFileWatcher extends EventEmitter {
|
|
16
|
+
config;
|
|
17
|
+
debounceTimers = new Map();
|
|
18
|
+
isWatching = false;
|
|
19
|
+
watchers = new Map();
|
|
20
|
+
constructor(config = {}) {
|
|
21
|
+
super();
|
|
22
|
+
this.config = {
|
|
23
|
+
debounceMs: config.debounceMs ?? 100,
|
|
24
|
+
ignoreInitial: config.ignoreInitial ?? true,
|
|
25
|
+
timeoutMs: config.timeoutMs ?? 30000, // 30 seconds default
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check if currently watching
|
|
30
|
+
*/
|
|
31
|
+
active() {
|
|
32
|
+
return this.isWatching;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Clean up all watchers and timers
|
|
36
|
+
*/
|
|
37
|
+
cleanup() {
|
|
38
|
+
// Clear all debounce timers
|
|
39
|
+
for (const timer of this.debounceTimers.values()) {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
this.debounceTimers.clear();
|
|
43
|
+
// Close all file watchers
|
|
44
|
+
for (const watcher of this.watchers.values()) {
|
|
45
|
+
watcher.close();
|
|
46
|
+
}
|
|
47
|
+
this.watchers.clear();
|
|
48
|
+
this.isWatching = false;
|
|
49
|
+
this.removeAllListeners();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Watch for a specific file to be created or modified
|
|
53
|
+
* Returns a promise that resolves when the file is detected
|
|
54
|
+
*/
|
|
55
|
+
async waitForFile(watchPath, fileName) {
|
|
56
|
+
const fullPath = join(watchPath, fileName);
|
|
57
|
+
// Fast-path: if file already exists, resolve immediately
|
|
58
|
+
if (await fileExists(fullPath)) {
|
|
59
|
+
return fullPath;
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timeoutId = this.config.timeoutMs > 0
|
|
63
|
+
? setTimeout(() => {
|
|
64
|
+
this.cleanup();
|
|
65
|
+
reject(new Error(`Timeout waiting for file: ${fullPath}`));
|
|
66
|
+
}, this.config.timeoutMs)
|
|
67
|
+
: undefined;
|
|
68
|
+
try {
|
|
69
|
+
// Set up file watcher
|
|
70
|
+
const watcher = watch(watchPath, { persistent: false }, (eventType, filename) => {
|
|
71
|
+
if (!filename)
|
|
72
|
+
return;
|
|
73
|
+
const eventPath = join(watchPath, filename);
|
|
74
|
+
// Check if this is the file we're waiting for
|
|
75
|
+
if (filename === fileName || eventPath === fullPath) {
|
|
76
|
+
this.handleFileEvent(eventType, eventPath);
|
|
77
|
+
// Verify file exists and is accessible; resolve on create or write
|
|
78
|
+
if (eventType === "rename" || eventType === "change") {
|
|
79
|
+
// Ensure the file actually exists before resolving
|
|
80
|
+
fileExists(fullPath).then((exists) => {
|
|
81
|
+
if (!exists)
|
|
82
|
+
return;
|
|
83
|
+
if (timeoutId)
|
|
84
|
+
clearTimeout(timeoutId);
|
|
85
|
+
this.cleanup();
|
|
86
|
+
resolve(fullPath);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
this.watchers.set(watchPath, watcher);
|
|
92
|
+
// Handle watcher errors
|
|
93
|
+
watcher.on("error", (error) => {
|
|
94
|
+
if (timeoutId)
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
this.cleanup();
|
|
97
|
+
reject(new Error(`File watcher error: ${error.message}`));
|
|
98
|
+
});
|
|
99
|
+
this.isWatching = true;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (timeoutId)
|
|
103
|
+
clearTimeout(timeoutId);
|
|
104
|
+
reject(new Error(`File watcher setup error: ${error}`));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Watch a directory for new session directories
|
|
110
|
+
* Emits events when new directories are created
|
|
111
|
+
*/
|
|
112
|
+
watchForSessions(sessionDirPath, onSessionCreated) {
|
|
113
|
+
try {
|
|
114
|
+
const watcher = watch(sessionDirPath, { persistent: false }, (eventType, filename) => {
|
|
115
|
+
if (!filename || eventType !== "rename")
|
|
116
|
+
return;
|
|
117
|
+
const fullPath = join(sessionDirPath, filename);
|
|
118
|
+
// Debounce rapid events
|
|
119
|
+
this.debounceEvent(fullPath, () => {
|
|
120
|
+
// Check if this is a new directory (potential session)
|
|
121
|
+
this.handleSessionEvent(fullPath, onSessionCreated);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
this.watchers.set(sessionDirPath, watcher);
|
|
125
|
+
watcher.on("error", (error) => {
|
|
126
|
+
this.emit("error", new Error(`Session watcher error: ${error.message}`));
|
|
127
|
+
});
|
|
128
|
+
this.isWatching = true;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.emit("error", new Error(`Session watcher setup error: ${error}`));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Debounce file system events to prevent duplicates
|
|
136
|
+
*/
|
|
137
|
+
debounceEvent(eventKey, callback) {
|
|
138
|
+
// Clear existing timer for this event
|
|
139
|
+
const existingTimer = this.debounceTimers.get(eventKey);
|
|
140
|
+
if (existingTimer) {
|
|
141
|
+
clearTimeout(existingTimer);
|
|
142
|
+
}
|
|
143
|
+
// Set new timer
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
this.debounceTimers.delete(eventKey);
|
|
146
|
+
callback();
|
|
147
|
+
}, this.config.debounceMs);
|
|
148
|
+
this.debounceTimers.set(eventKey, timer);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Handle file system events with debouncing
|
|
152
|
+
*/
|
|
153
|
+
handleFileEvent(eventType, filePath) {
|
|
154
|
+
const event = {
|
|
155
|
+
eventType: eventType,
|
|
156
|
+
filePath,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
};
|
|
159
|
+
this.emit("fileEvent", event);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Handle new session directory creation
|
|
163
|
+
*/
|
|
164
|
+
async handleSessionEvent(sessionPath, onSessionCreated) {
|
|
165
|
+
try {
|
|
166
|
+
// Check if this is actually a directory and has session files
|
|
167
|
+
const stats = await import("fs").then((fs) => fs.promises.stat(sessionPath));
|
|
168
|
+
if (!stats.isDirectory())
|
|
169
|
+
return;
|
|
170
|
+
// Extract session ID from directory name
|
|
171
|
+
const sessionId = sessionPath.split("/").pop() ?? "";
|
|
172
|
+
if (!sessionId)
|
|
173
|
+
return;
|
|
174
|
+
// Verify it's a valid session (has request.json)
|
|
175
|
+
const requestFile = join(sessionPath, "request.json");
|
|
176
|
+
try {
|
|
177
|
+
await import("fs").then((fs) => fs.promises.access(requestFile));
|
|
178
|
+
onSessionCreated(sessionId, sessionPath);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Not a valid session directory
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Error accessing directory - ignore
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* TUI Session Watcher -专门用于 TUI 检测新会话
|
|
193
|
+
*/
|
|
194
|
+
export class TUISessionWatcher {
|
|
195
|
+
/**
|
|
196
|
+
* Get the session directory path being watched
|
|
197
|
+
*/
|
|
198
|
+
get watchedPath() {
|
|
199
|
+
return this.sessionDirPath;
|
|
200
|
+
}
|
|
201
|
+
fileWatcher;
|
|
202
|
+
sessionDirPath;
|
|
203
|
+
constructor(config) {
|
|
204
|
+
// Resolve session directory using XDG-compliant path
|
|
205
|
+
const sessionConfig = {
|
|
206
|
+
baseDir: config?.baseDir ?? "~/.local/share/auq/sessions",
|
|
207
|
+
};
|
|
208
|
+
this.sessionDirPath = resolveSessionDirectory(sessionConfig.baseDir);
|
|
209
|
+
this.fileWatcher = new PromiseFileWatcher({
|
|
210
|
+
debounceMs: config?.debounceMs ?? 200,
|
|
211
|
+
ignoreInitial: config?.ignoreInitial ?? true,
|
|
212
|
+
timeoutMs: config?.timeoutMs ?? 60000, // 1 minute for TUI
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Start watching for new sessions
|
|
217
|
+
*/
|
|
218
|
+
startWatching(onNewSession) {
|
|
219
|
+
this.fileWatcher.watchForSessions(this.sessionDirPath, onNewSession);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Stop watching and clean up
|
|
223
|
+
*/
|
|
224
|
+
stop() {
|
|
225
|
+
this.fileWatcher.cleanup();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-related TypeScript interfaces and types for AskUserQuestions MCP server
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* File names for session storage
|
|
6
|
+
*/
|
|
7
|
+
export const SESSION_FILES = {
|
|
8
|
+
ANSWERS: "answers.json",
|
|
9
|
+
REQUEST: "request.json",
|
|
10
|
+
STATUS: "status.json",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Default session configuration
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_SESSION_CONFIG = {
|
|
16
|
+
baseDir: "~/.local/share/auq/sessions", // Will be resolved to actual path
|
|
17
|
+
maxSessions: 100,
|
|
18
|
+
retentionPeriod: 604800000, // 7 days in milliseconds
|
|
19
|
+
sessionTimeout: 0, // 0 = infinite timeout (wait indefinitely for user)
|
|
20
|
+
};
|