auq-mcp-server 2.2.2 → 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.
Files changed (62) hide show
  1. package/README.md +82 -0
  2. package/dist/bin/auq.js +45 -39
  3. package/dist/bin/tui-app.js +78 -8
  4. package/dist/package.json +1 -1
  5. package/dist/src/__tests__/server.abort.test.js +214 -0
  6. package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
  7. package/dist/src/cli/commands/__tests__/config.test.js +218 -0
  8. package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
  9. package/dist/src/cli/commands/answer.js +128 -0
  10. package/dist/src/cli/commands/config.js +263 -0
  11. package/dist/src/cli/commands/sessions.js +164 -0
  12. package/dist/src/cli/utils.js +95 -0
  13. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  14. package/dist/src/config/defaults.js +3 -0
  15. package/dist/src/config/types.js +4 -0
  16. package/dist/src/core/ask-user-questions.js +3 -2
  17. package/dist/src/i18n/locales/en.js +8 -1
  18. package/dist/src/i18n/locales/ko.js +8 -1
  19. package/dist/src/server.js +64 -11
  20. package/dist/src/session/SessionManager.js +69 -4
  21. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  22. package/dist/src/tui/ThemeProvider.js +2 -1
  23. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  24. package/dist/src/tui/components/ConfirmationDialog.js +5 -4
  25. package/dist/src/tui/components/Footer.js +24 -23
  26. package/dist/src/tui/components/ReviewScreen.js +2 -1
  27. package/dist/src/tui/components/SessionDots.js +33 -4
  28. package/dist/src/tui/components/SessionPicker.js +27 -18
  29. package/dist/src/tui/components/Spinner.js +19 -0
  30. package/dist/src/tui/components/StepperView.js +71 -7
  31. package/dist/src/tui/components/WaitingScreen.js +2 -1
  32. package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +134 -0
  33. package/dist/src/tui/components/__tests__/Footer.test.js +121 -0
  34. package/dist/src/tui/components/__tests__/ReviewScreen.test.js +89 -0
  35. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  36. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  37. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  38. package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +135 -0
  39. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  40. package/dist/src/tui/components/__tests__/WaitingScreen.test.js +60 -0
  41. package/dist/src/tui/constants/keybindings.js +40 -0
  42. package/dist/src/tui/session-watcher.js +50 -0
  43. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  44. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  45. package/dist/src/tui/themes/dark.js +7 -0
  46. package/dist/src/tui/themes/dracula.js +7 -0
  47. package/dist/src/tui/themes/github-dark.js +7 -0
  48. package/dist/src/tui/themes/github-light.js +7 -0
  49. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  50. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  51. package/dist/src/tui/themes/light.js +7 -0
  52. package/dist/src/tui/themes/monokai.js +7 -0
  53. package/dist/src/tui/themes/nord.js +7 -0
  54. package/dist/src/tui/themes/one-dark.js +7 -0
  55. package/dist/src/tui/themes/rose-pine.js +7 -0
  56. package/dist/src/tui/themes/solarized-dark.js +7 -0
  57. package/dist/src/tui/themes/solarized-light.js +7 -0
  58. package/dist/src/tui/themes/tokyo-night.js +7 -0
  59. package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
  60. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  61. package/dist/src/tui/utils/staleDetection.js +51 -0
  62. 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
  });
@@ -8,6 +8,9 @@ export const DEFAULT_CONFIG = {
8
8
  language: "auto",
9
9
  theme: "system",
10
10
  autoSelectRecommended: true,
11
+ staleThreshold: 7200000, // 2 hours in ms
12
+ notifyOnStale: true,
13
+ staleAction: "warn",
11
14
  notifications: {
12
15
  enabled: true,
13
16
  sound: true,
@@ -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,13 +39,14 @@ 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...",
45
46
  },
46
47
  input: {
47
48
  customAnswerLabel: "Custom answer",
48
- customAnswerHint: "(Press Tab to enter custom answer)",
49
+ customAnswerHint: "(Tab to submit)",
49
50
  otherCustom: "Other (custom)",
50
51
  placeholder: "Type your answer (Enter = newline, Tab = done)",
51
52
  singleLinePlaceholder: "Type here...",
@@ -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,13 +39,14 @@ export const ko = {
39
39
  copied: "클립보드에 복사됨",
40
40
  saved: "저장됨",
41
41
  error: "오류",
42
+ staleSession: "세션 \"{title}\"이 고아 상태일 수 있습니다 ({hours}시간 전 생성)",
42
43
  },
43
44
  stepper: {
44
45
  submitting: "답변 제출 중...",
45
46
  },
46
47
  input: {
47
48
  customAnswerLabel: "직접 입력",
48
- customAnswerHint: "(Tab 눌러 직접 입력)",
49
+ customAnswerHint: "(Tab으로 제출)",
49
50
  otherCustom: "기타 (직접 입력)",
50
51
  placeholder: "답변을 입력하세요 (Enter = 줄바꿈, Tab = 완료)",
51
52
  singleLinePlaceholder: "여기에 입력...",
@@ -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
  };
@@ -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
- const { formattedResponse, sessionId } = await askUserQuestionsCore.ask(args.questions, callId, workingDirectory);
50
- log.info("Session completed successfully", { sessionId, callId });
51
- // Return formatted response to AI model
52
- return {
53
- content: [
54
- {
55
- text: formattedResponse,
56
- type: "text",
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");