auq-mcp-server 2.3.0 → 2.4.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/README.md +82 -0
- package/dist/bin/auq.js +47 -93
- package/dist/bin/tui-app.js +69 -6
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +164 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/types.js +4 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +7 -0
- package/dist/src/i18n/locales/ko.js +7 -0
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +25 -17
- package/dist/src/tui/components/Spinner.js +19 -0
- package/dist/src/tui/components/StepperView.js +68 -5
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- package/package.json +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Sessions Command — `auq sessions list` and `auq sessions dismiss`
|
|
3
|
+
* Manages listing and dismissing/archiving sessions.
|
|
4
|
+
*/
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { SessionManager } from "../../session/SessionManager.js";
|
|
8
|
+
import { getSessionDirectory } from "../../session/utils.js";
|
|
9
|
+
import { loadConfig } from "../../config/ConfigLoader.js";
|
|
10
|
+
import { formatAge, outputResult, parseFlags, resolveArchiveDirectory, } from "../utils.js";
|
|
11
|
+
async function sessionsList(args) {
|
|
12
|
+
const { flags } = parseFlags(args);
|
|
13
|
+
const jsonMode = flags.json === true;
|
|
14
|
+
const filterStale = flags.stale === true;
|
|
15
|
+
const filterAll = flags.all === true;
|
|
16
|
+
// --pending is same as default
|
|
17
|
+
// Initialise SessionManager
|
|
18
|
+
const sessionManager = new SessionManager({
|
|
19
|
+
baseDir: getSessionDirectory(),
|
|
20
|
+
});
|
|
21
|
+
await sessionManager.initialize();
|
|
22
|
+
// Load staleThreshold from config
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
const staleThreshold = config.staleThreshold ?? 7200000;
|
|
25
|
+
// Get all session IDs
|
|
26
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
27
|
+
// Build entries
|
|
28
|
+
const entries = [];
|
|
29
|
+
for (const sessionId of sessionIds) {
|
|
30
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
31
|
+
if (!status)
|
|
32
|
+
continue;
|
|
33
|
+
const createdAt = status.createdAt;
|
|
34
|
+
const ageMs = Date.now() - new Date(createdAt).getTime();
|
|
35
|
+
const stale = ageMs > staleThreshold;
|
|
36
|
+
const age = formatAge(createdAt);
|
|
37
|
+
const isPending = status.status === "pending" || status.status === "in-progress";
|
|
38
|
+
// Apply filter
|
|
39
|
+
if (filterAll) {
|
|
40
|
+
// Show all statuses
|
|
41
|
+
}
|
|
42
|
+
else if (filterStale) {
|
|
43
|
+
// Only stale sessions that are pending/in-progress
|
|
44
|
+
if (!isPending || !stale)
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Default / --pending: only pending/in-progress
|
|
49
|
+
if (!isPending)
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const request = await sessionManager.getSessionRequest(sessionId);
|
|
53
|
+
const questionCount = request?.questions?.length ?? status.totalQuestions ?? 0;
|
|
54
|
+
entries.push({
|
|
55
|
+
sessionId,
|
|
56
|
+
status: status.status,
|
|
57
|
+
createdAt,
|
|
58
|
+
age,
|
|
59
|
+
stale,
|
|
60
|
+
questionCount,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Sort by createdAt descending (newest first)
|
|
64
|
+
entries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
65
|
+
// Output
|
|
66
|
+
if (jsonMode) {
|
|
67
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
console.log("No sessions found.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const staleIndicator = entry.stale ? " ⚠" : "";
|
|
76
|
+
console.log(`${entry.sessionId} ${entry.status} ${entry.age} questions: ${entry.questionCount}${staleIndicator}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ── Sessions Dismiss ───────────────────────────────────────────────
|
|
80
|
+
async function sessionsDismiss(args) {
|
|
81
|
+
const { flags, positionals } = parseFlags(args);
|
|
82
|
+
const jsonMode = flags.json === true;
|
|
83
|
+
const force = flags.force === true;
|
|
84
|
+
const sessionId = positionals[0];
|
|
85
|
+
// ── Validate sessionId ──────────────────────────────────────────
|
|
86
|
+
if (!sessionId) {
|
|
87
|
+
outputResult({
|
|
88
|
+
success: false,
|
|
89
|
+
error: "Missing session ID. Usage: auq sessions dismiss <sessionId> [--force] [--json]",
|
|
90
|
+
}, jsonMode);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// ── Initialise SessionManager ───────────────────────────────────
|
|
95
|
+
const sessionManager = new SessionManager({
|
|
96
|
+
baseDir: getSessionDirectory(),
|
|
97
|
+
});
|
|
98
|
+
await sessionManager.initialize();
|
|
99
|
+
// ── Verify session exists ──────────────────────────────────────
|
|
100
|
+
const exists = await sessionManager.sessionExists(sessionId);
|
|
101
|
+
if (!exists) {
|
|
102
|
+
outputResult({ success: false, error: `Session not found: ${sessionId}` }, jsonMode);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// ── Check stale status ─────────────────────────────────────────
|
|
107
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
108
|
+
const config = loadConfig();
|
|
109
|
+
const staleThreshold = config.staleThreshold ?? 7200000;
|
|
110
|
+
const ageMs = status
|
|
111
|
+
? Date.now() - new Date(status.createdAt).getTime()
|
|
112
|
+
: 0;
|
|
113
|
+
const isStale = ageMs > staleThreshold;
|
|
114
|
+
if (!isStale && !force) {
|
|
115
|
+
outputResult({
|
|
116
|
+
success: false,
|
|
117
|
+
error: "Session is not stale. Use --force to dismiss anyway.",
|
|
118
|
+
}, jsonMode);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// ── Archive session ────────────────────────────────────────────
|
|
123
|
+
const archiveBase = resolveArchiveDirectory();
|
|
124
|
+
const archiveDir = join(archiveBase, sessionId);
|
|
125
|
+
await fs.mkdir(archiveDir, { recursive: true });
|
|
126
|
+
// Copy all files from session directory to archive
|
|
127
|
+
const sessionDir = join(getSessionDirectory(), sessionId);
|
|
128
|
+
const files = await fs.readdir(sessionDir);
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
await fs.copyFile(join(sessionDir, file), join(archiveDir, file));
|
|
131
|
+
}
|
|
132
|
+
// ── Remove from active ─────────────────────────────────────────
|
|
133
|
+
await sessionManager.deleteSession(sessionId);
|
|
134
|
+
// ── Output ──────────────────────────────────────────────────────
|
|
135
|
+
outputResult({ success: true, sessionId, archivedTo: archiveDir }, jsonMode);
|
|
136
|
+
if (!jsonMode) {
|
|
137
|
+
console.log(`Session ${sessionId} dismissed and archived to ${archiveDir}.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── Sessions Command Dispatcher ────────────────────────────────────
|
|
141
|
+
export async function runSessionsCommand(args) {
|
|
142
|
+
const subcommand = args[0];
|
|
143
|
+
switch (subcommand) {
|
|
144
|
+
case "list":
|
|
145
|
+
return sessionsList(args.slice(1));
|
|
146
|
+
case "dismiss":
|
|
147
|
+
return sessionsDismiss(args.slice(1));
|
|
148
|
+
default:
|
|
149
|
+
console.log("Usage: auq sessions <subcommand>", "\n");
|
|
150
|
+
console.log("Subcommands:");
|
|
151
|
+
console.log(" list [--pending|--stale|--all] [--json] List sessions");
|
|
152
|
+
console.log(" dismiss <sessionId> [--force] [--json] Dismiss/archive a session");
|
|
153
|
+
console.log("");
|
|
154
|
+
console.log("Examples:");
|
|
155
|
+
console.log(" auq sessions list");
|
|
156
|
+
console.log(" auq sessions list --stale --json");
|
|
157
|
+
console.log(" auq sessions dismiss <sessionId>");
|
|
158
|
+
console.log(" auq sessions dismiss <sessionId> --force");
|
|
159
|
+
if (subcommand !== undefined) {
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Parse CLI flags from an args array.
|
|
5
|
+
* Handles --flag (boolean) and --flag value patterns.
|
|
6
|
+
* Everything not starting with -- goes to positionals.
|
|
7
|
+
*/
|
|
8
|
+
export function parseFlags(args) {
|
|
9
|
+
const flags = {};
|
|
10
|
+
const positionals = [];
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (arg.startsWith("--")) {
|
|
14
|
+
const key = arg.slice(2);
|
|
15
|
+
const next = args[i + 1];
|
|
16
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
17
|
+
flags[key] = next;
|
|
18
|
+
i++; // skip next arg — it was consumed as a value
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
flags[key] = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
positionals.push(arg);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { flags, positionals };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Standard CLI output helper.
|
|
32
|
+
* - jsonMode: console.log(JSON.stringify(result, null, 2))
|
|
33
|
+
* - else: human-readable formatted output
|
|
34
|
+
*/
|
|
35
|
+
export function outputResult(result, jsonMode) {
|
|
36
|
+
if (jsonMode) {
|
|
37
|
+
console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
const message = typeof result.error === "string"
|
|
42
|
+
? result.error
|
|
43
|
+
: typeof result.message === "string"
|
|
44
|
+
? result.message
|
|
45
|
+
: "Unknown error";
|
|
46
|
+
console.error(`Error: ${message}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Human-readable: print each key=value pair (skip success)
|
|
50
|
+
for (const [key, value] of Object.entries(result)) {
|
|
51
|
+
if (key === "success")
|
|
52
|
+
continue;
|
|
53
|
+
if (typeof value === "object" && value !== null) {
|
|
54
|
+
console.log(`${key}:`);
|
|
55
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
56
|
+
console.log(` ${subKey} = ${String(subValue)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log(`${key} = ${String(value)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Format age from a timestamp to a human-readable string.
|
|
66
|
+
* Returns "Xm ago", "Xh ago", "Xd ago", etc.
|
|
67
|
+
*/
|
|
68
|
+
export function formatAge(createdAt) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const then = createdAt instanceof Date
|
|
71
|
+
? createdAt.getTime()
|
|
72
|
+
: new Date(createdAt).getTime();
|
|
73
|
+
const diffMs = now - then;
|
|
74
|
+
if (diffMs < 0)
|
|
75
|
+
return "just now";
|
|
76
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
77
|
+
const minutes = Math.floor(seconds / 60);
|
|
78
|
+
const hours = Math.floor(minutes / 60);
|
|
79
|
+
const days = Math.floor(hours / 24);
|
|
80
|
+
if (days > 0)
|
|
81
|
+
return `${days}d ago`;
|
|
82
|
+
if (hours > 0)
|
|
83
|
+
return `${hours}h ago`;
|
|
84
|
+
if (minutes > 0)
|
|
85
|
+
return `${minutes}m ago`;
|
|
86
|
+
return `${seconds}s ago`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the archive directory for dismissed sessions.
|
|
90
|
+
* Uses the XDG data home standard: ~/.local/share/auq/archive
|
|
91
|
+
*/
|
|
92
|
+
export function resolveArchiveDirectory() {
|
|
93
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
|
94
|
+
return join(xdgDataHome, "auq", "archive");
|
|
95
|
+
}
|
|
@@ -91,4 +91,45 @@ describe("ConfigLoader", () => {
|
|
|
91
91
|
expect(paths.globalExists).toBe(true);
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
|
+
describe("stale/orphan session config options", () => {
|
|
95
|
+
it("should include stale config defaults when no config files exist", () => {
|
|
96
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
expect(config.staleThreshold).toBe(7200000);
|
|
99
|
+
expect(config.notifyOnStale).toBe(true);
|
|
100
|
+
expect(config.staleAction).toBe("warn");
|
|
101
|
+
});
|
|
102
|
+
it("should load staleThreshold from config file", () => {
|
|
103
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
104
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ staleThreshold: 3600000 }));
|
|
105
|
+
const config = loadConfig();
|
|
106
|
+
expect(config.staleThreshold).toBe(3600000);
|
|
107
|
+
});
|
|
108
|
+
it("should load staleAction from config file", () => {
|
|
109
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
110
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ staleAction: "archive" }));
|
|
111
|
+
const config = loadConfig();
|
|
112
|
+
expect(config.staleAction).toBe("archive");
|
|
113
|
+
});
|
|
114
|
+
it("should load notifyOnStale from config file", () => {
|
|
115
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
116
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ notifyOnStale: false }));
|
|
117
|
+
const config = loadConfig();
|
|
118
|
+
expect(config.notifyOnStale).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
it("should fall back to default for invalid staleAction value", () => {
|
|
121
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
122
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ staleAction: "invalid_action" }));
|
|
123
|
+
const config = loadConfig();
|
|
124
|
+
// Invalid enum value should be ignored, default used
|
|
125
|
+
expect(config.staleAction).toBe(DEFAULT_CONFIG.staleAction);
|
|
126
|
+
});
|
|
127
|
+
it("should reject negative staleThreshold value", () => {
|
|
128
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
129
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ staleThreshold: -1000 }));
|
|
130
|
+
const config = loadConfig();
|
|
131
|
+
// Negative value should be rejected by min(0), default used
|
|
132
|
+
expect(config.staleThreshold).toBe(DEFAULT_CONFIG.staleThreshold);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
94
135
|
});
|
package/dist/src/config/types.js
CHANGED
|
@@ -21,6 +21,10 @@ export const AUQConfigSchema = z.object({
|
|
|
21
21
|
language: z.string().default("auto"),
|
|
22
22
|
theme: z.string().default("system"),
|
|
23
23
|
autoSelectRecommended: z.boolean().default(true),
|
|
24
|
+
// Stale/Orphan Session Detection
|
|
25
|
+
staleThreshold: z.number().min(0).default(7200000), // 2 hours in ms
|
|
26
|
+
notifyOnStale: z.boolean().default(true),
|
|
27
|
+
staleAction: z.enum(["warn", "remove", "archive"]).default("warn"),
|
|
24
28
|
// Notifications (OSC 9/99)
|
|
25
29
|
notifications: NotificationConfigSchema.default({
|
|
26
30
|
enabled: true,
|
|
@@ -22,14 +22,15 @@ export const createAskUserQuestionsCore = (options = {}) => {
|
|
|
22
22
|
title: question.title,
|
|
23
23
|
multiSelect: question.multiSelect,
|
|
24
24
|
}));
|
|
25
|
-
const ask = async (questions, callId, workingDirectory) => {
|
|
25
|
+
const ask = async (questions, callId, workingDirectory, signal) => {
|
|
26
26
|
await ensureInitialized();
|
|
27
27
|
const parsedQuestions = QuestionsSchema.parse(questions);
|
|
28
|
-
return sessionManager.startSession(normalizeQuestions(parsedQuestions), callId, workingDirectory);
|
|
28
|
+
return sessionManager.startSession(normalizeQuestions(parsedQuestions), callId, workingDirectory, signal);
|
|
29
29
|
};
|
|
30
30
|
return {
|
|
31
31
|
ask,
|
|
32
32
|
cleanupExpiredSessions: () => sessionManager.cleanupExpiredSessions(),
|
|
33
33
|
ensureInitialized,
|
|
34
|
+
markAbandoned: (sessionId) => sessionManager.updateSessionStatus(sessionId, "abandoned"),
|
|
34
35
|
};
|
|
35
36
|
};
|
|
@@ -39,6 +39,7 @@ export const en = {
|
|
|
39
39
|
copied: "Copied to clipboard",
|
|
40
40
|
saved: "Saved",
|
|
41
41
|
error: "Error",
|
|
42
|
+
staleSession: "Session \"{title}\" may be orphaned (created {hours}h ago)",
|
|
42
43
|
},
|
|
43
44
|
stepper: {
|
|
44
45
|
submitting: "Submitting answers...",
|
|
@@ -72,4 +73,10 @@ export const en = {
|
|
|
72
73
|
ui: {
|
|
73
74
|
themeLabel: "theme:",
|
|
74
75
|
},
|
|
76
|
+
abandoned: {
|
|
77
|
+
title: "AI Disconnected",
|
|
78
|
+
message: "The AI has disconnected. Do you still want to answer?",
|
|
79
|
+
continue: "Answer anyway",
|
|
80
|
+
cancel: "Cancel",
|
|
81
|
+
},
|
|
75
82
|
};
|
|
@@ -39,6 +39,7 @@ export const ko = {
|
|
|
39
39
|
copied: "클립보드에 복사됨",
|
|
40
40
|
saved: "저장됨",
|
|
41
41
|
error: "오류",
|
|
42
|
+
staleSession: "세션 \"{title}\"이 고아 상태일 수 있습니다 ({hours}시간 전 생성)",
|
|
42
43
|
},
|
|
43
44
|
stepper: {
|
|
44
45
|
submitting: "답변 제출 중...",
|
|
@@ -72,4 +73,10 @@ export const ko = {
|
|
|
72
73
|
ui: {
|
|
73
74
|
themeLabel: "테마:",
|
|
74
75
|
},
|
|
76
|
+
abandoned: {
|
|
77
|
+
title: "AI 연결 끊김",
|
|
78
|
+
message: "AI가 disconnect되었습니다. 그래도 답변하시겠습니까?",
|
|
79
|
+
continue: "답변하기",
|
|
80
|
+
cancel: "취소",
|
|
81
|
+
},
|
|
75
82
|
};
|
package/dist/src/server.js
CHANGED
|
@@ -3,6 +3,8 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { AskUserQuestionsParametersSchema, createAskUserQuestionsCore, } from "./core/ask-user-questions.js";
|
|
4
4
|
import { TOOL_DESCRIPTION } from "./shared/schemas.js";
|
|
5
5
|
const askUserQuestionsCore = createAskUserQuestionsCore();
|
|
6
|
+
// Track active requests with their AbortControllers for disconnect handling
|
|
7
|
+
const activeRequests = new Map();
|
|
6
8
|
const server = new FastMCP({
|
|
7
9
|
name: "AskUserQuestions",
|
|
8
10
|
instructions: "MCP server for asking users structured questions during AI execution. " +
|
|
@@ -41,22 +43,50 @@ server.addTool({
|
|
|
41
43
|
}
|
|
42
44
|
// Generate a per-tool-call ID and persist it with the session
|
|
43
45
|
const callId = randomUUID();
|
|
46
|
+
// Create AbortController for this request to handle disconnects
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
activeRequests.set(callId, { controller });
|
|
44
49
|
// Capture working directory if available from MCP context
|
|
45
50
|
// Note: MCP protocol does not currently expose client working directory
|
|
46
51
|
// This field is reserved for future protocol enhancements
|
|
47
52
|
const workingDirectory = ctx
|
|
48
53
|
.workingDirectory;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
try {
|
|
55
|
+
const { formattedResponse, sessionId } = await askUserQuestionsCore.ask(args.questions, callId, workingDirectory, controller.signal);
|
|
56
|
+
// Update entry with sessionId for disconnect handler
|
|
57
|
+
const entry = activeRequests.get(callId);
|
|
58
|
+
if (entry) {
|
|
59
|
+
entry.sessionId = sessionId;
|
|
60
|
+
}
|
|
61
|
+
log.info("Session completed successfully", { sessionId, callId });
|
|
62
|
+
// Return formatted response to AI model
|
|
63
|
+
return {
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
text: formattedResponse,
|
|
67
|
+
type: "text",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// Handle abort (AI client disconnected)
|
|
74
|
+
if (error instanceof Error && error.message === "ABORTED") {
|
|
75
|
+
log.warn("Session aborted: AI client disconnected", { callId });
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
text: "Session aborted: AI client disconnected",
|
|
80
|
+
type: "text",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
throw error; // Re-throw other errors to outer catch
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
activeRequests.delete(callId);
|
|
89
|
+
}
|
|
60
90
|
}
|
|
61
91
|
catch (error) {
|
|
62
92
|
log.error("Session failed", { error: String(error) });
|
|
@@ -72,6 +102,29 @@ server.addTool({
|
|
|
72
102
|
},
|
|
73
103
|
parameters: AskUserQuestionsParametersSchema,
|
|
74
104
|
});
|
|
105
|
+
// Handle AI client disconnections gracefully
|
|
106
|
+
// Note: FastMCP disconnect event support depends on the version.
|
|
107
|
+
// If the event is not available, stale detection handles orphaned sessions as fallback.
|
|
108
|
+
try {
|
|
109
|
+
server.on("disconnect", async () => {
|
|
110
|
+
for (const [callId, entry] of activeRequests.entries()) {
|
|
111
|
+
try {
|
|
112
|
+
entry.controller.abort();
|
|
113
|
+
if (entry.sessionId) {
|
|
114
|
+
await askUserQuestionsCore.markAbandoned(entry.sessionId).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Silently ignore errors during disconnect cleanup
|
|
119
|
+
}
|
|
120
|
+
activeRequests.delete(callId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// FastMCP version may not support disconnect events
|
|
126
|
+
// Graceful fallback: stale detection handles orphaned sessions
|
|
127
|
+
}
|
|
75
128
|
// Start the server with stdio transport
|
|
76
129
|
server.start({
|
|
77
130
|
transportType: "stdio",
|
|
@@ -141,6 +141,29 @@ export class SessionManager {
|
|
|
141
141
|
async getSessionAnswers(sessionId) {
|
|
142
142
|
return this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Get all pending sessions, optionally including abandoned ones
|
|
146
|
+
*/
|
|
147
|
+
async getPendingSessions(options) {
|
|
148
|
+
const sessionIds = await this.getAllSessionIds();
|
|
149
|
+
const pendingSessions = [];
|
|
150
|
+
for (const sessionId of sessionIds) {
|
|
151
|
+
try {
|
|
152
|
+
const status = await this.getSessionStatus(sessionId);
|
|
153
|
+
if (!status)
|
|
154
|
+
continue;
|
|
155
|
+
const isPending = status.status === "pending" || status.status === "in-progress";
|
|
156
|
+
const isAbandoned = status.status === "abandoned";
|
|
157
|
+
if (isPending || (options?.includeAbandoned && isAbandoned)) {
|
|
158
|
+
pendingSessions.push(sessionId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return pendingSessions;
|
|
166
|
+
}
|
|
144
167
|
/**
|
|
145
168
|
* Get session count
|
|
146
169
|
*/
|
|
@@ -160,6 +183,13 @@ export class SessionManager {
|
|
|
160
183
|
async getSessionStatus(sessionId) {
|
|
161
184
|
return this.readSessionFile(sessionId, SESSION_FILES.STATUS);
|
|
162
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Check if a session has been abandoned
|
|
188
|
+
*/
|
|
189
|
+
async isAbandoned(sessionId) {
|
|
190
|
+
const status = await this.getSessionStatus(sessionId);
|
|
191
|
+
return status?.status === "abandoned";
|
|
192
|
+
}
|
|
163
193
|
/**
|
|
164
194
|
* Initialize the session manager - create base directories
|
|
165
195
|
*/
|
|
@@ -239,9 +269,21 @@ export class SessionManager {
|
|
|
239
269
|
* @returns Object containing sessionId and formatted response text
|
|
240
270
|
* @throws Error if timeout occurs, validation fails, or file operations fail
|
|
241
271
|
*/
|
|
242
|
-
async startSession(questions, callId, workingDirectory) {
|
|
272
|
+
async startSession(questions, callId, workingDirectory, signal) {
|
|
243
273
|
// Step 1: Create the session
|
|
244
274
|
const sessionId = await this.createSession(questions, workingDirectory);
|
|
275
|
+
// Step 1.5: Register abort handler if signal provided
|
|
276
|
+
let abortHandler;
|
|
277
|
+
if (signal) {
|
|
278
|
+
if (signal.aborted) {
|
|
279
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
280
|
+
throw new Error("ABORTED");
|
|
281
|
+
}
|
|
282
|
+
abortHandler = () => {
|
|
283
|
+
this.updateSessionStatus(sessionId, "abandoned").catch(() => { });
|
|
284
|
+
};
|
|
285
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
286
|
+
}
|
|
245
287
|
// Optionally attach callId and workingDirectory metadata to request and status
|
|
246
288
|
if (callId || workingDirectory) {
|
|
247
289
|
try {
|
|
@@ -274,11 +316,23 @@ export class SessionManager {
|
|
|
274
316
|
: 0; // Also infinite if session is infinite
|
|
275
317
|
// Step 3: Wait for answers with timeout
|
|
276
318
|
try {
|
|
277
|
-
await this.waitForAnswers(sessionId, watcherTimeout, callId);
|
|
319
|
+
await this.waitForAnswers(sessionId, watcherTimeout, callId, signal);
|
|
278
320
|
}
|
|
279
321
|
catch (error) {
|
|
322
|
+
// Check if session was aborted (AI disconnected)
|
|
323
|
+
if (error instanceof Error && error.message === "ABORTED") {
|
|
324
|
+
// Clean up abort handler
|
|
325
|
+
if (abortHandler && signal) {
|
|
326
|
+
signal.removeEventListener("abort", abortHandler);
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
280
330
|
// Check if session was rejected by user
|
|
281
331
|
if (error instanceof Error && error.message === "SESSION_REJECTED") {
|
|
332
|
+
// Clean up abort handler
|
|
333
|
+
if (abortHandler && signal) {
|
|
334
|
+
signal.removeEventListener("abort", abortHandler);
|
|
335
|
+
}
|
|
282
336
|
// Get session status to retrieve rejection reason
|
|
283
337
|
const status = await this.getSessionStatus(sessionId);
|
|
284
338
|
const reason = status?.rejectionReason;
|
|
@@ -290,7 +344,6 @@ export class SessionManager {
|
|
|
290
344
|
else {
|
|
291
345
|
formattedResponse += "No reason provided.\n\n";
|
|
292
346
|
}
|
|
293
|
-
// formattedResponse += "The user chose not to answer these questions at this time.";
|
|
294
347
|
return {
|
|
295
348
|
formattedResponse,
|
|
296
349
|
sessionId,
|
|
@@ -331,6 +384,10 @@ export class SessionManager {
|
|
|
331
384
|
const formattedResponse = ResponseFormatter.formatUserResponse(answers, request.questions);
|
|
332
385
|
// Step 7: Update final status
|
|
333
386
|
await this.updateSessionStatus(sessionId, "completed");
|
|
387
|
+
// Clean up abort handler after successful completion
|
|
388
|
+
if (abortHandler && signal) {
|
|
389
|
+
signal.removeEventListener("abort", abortHandler);
|
|
390
|
+
}
|
|
334
391
|
// Step 8: Return results
|
|
335
392
|
return {
|
|
336
393
|
formattedResponse,
|
|
@@ -338,6 +395,10 @@ export class SessionManager {
|
|
|
338
395
|
};
|
|
339
396
|
}
|
|
340
397
|
catch (error) {
|
|
398
|
+
// Clean up abort handler on error
|
|
399
|
+
if (abortHandler && signal) {
|
|
400
|
+
signal.removeEventListener("abort", abortHandler);
|
|
401
|
+
}
|
|
341
402
|
// Ensure any errors are properly propagated with session context
|
|
342
403
|
if (error instanceof Error) {
|
|
343
404
|
throw error;
|
|
@@ -416,7 +477,7 @@ export class SessionManager {
|
|
|
416
477
|
* Wait for user answers to be submitted for a specific session
|
|
417
478
|
* Returns the session ID when answers are detected, or rejects on timeout
|
|
418
479
|
*/
|
|
419
|
-
async waitForAnswers(sessionId, timeoutMs, expectedCallId) {
|
|
480
|
+
async waitForAnswers(sessionId, timeoutMs, expectedCallId, signal) {
|
|
420
481
|
const sessionDir = this.getSessionDir(sessionId);
|
|
421
482
|
const answersPath = join(sessionDir, SESSION_FILES.ANSWERS);
|
|
422
483
|
const startTime = Date.now();
|
|
@@ -448,6 +509,10 @@ export class SessionManager {
|
|
|
448
509
|
if (status && status.status === "rejected") {
|
|
449
510
|
throw new Error("SESSION_REJECTED");
|
|
450
511
|
}
|
|
512
|
+
// Check for abort signal
|
|
513
|
+
if (signal?.aborted) {
|
|
514
|
+
throw new Error("ABORTED");
|
|
515
|
+
}
|
|
451
516
|
// Check for timeout
|
|
452
517
|
if (timeoutMs && timeoutMs > 0 && Date.now() - startTime > timeoutMs) {
|
|
453
518
|
throw new Error("Timeout waiting for user response");
|