auq-mcp-server 2.3.0 → 2.5.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 (71) hide show
  1. package/README.md +122 -0
  2. package/dist/bin/auq.js +87 -93
  3. package/dist/bin/tui-app.js +183 -7
  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 +300 -0
  12. package/dist/src/cli/commands/update.js +124 -0
  13. package/dist/src/cli/utils.js +95 -0
  14. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  15. package/dist/src/config/__tests__/updateCheck.test.js +34 -0
  16. package/dist/src/config/defaults.js +5 -0
  17. package/dist/src/config/types.js +6 -0
  18. package/dist/src/core/ask-user-questions.js +3 -2
  19. package/dist/src/i18n/locales/en.js +7 -0
  20. package/dist/src/i18n/locales/ko.js +7 -0
  21. package/dist/src/server.js +64 -11
  22. package/dist/src/session/SessionManager.js +69 -4
  23. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  24. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  25. package/dist/src/tui/components/Footer.js +4 -1
  26. package/dist/src/tui/components/Header.js +3 -1
  27. package/dist/src/tui/components/SessionDots.js +33 -4
  28. package/dist/src/tui/components/SessionPicker.js +25 -17
  29. package/dist/src/tui/components/StepperView.js +68 -5
  30. package/dist/src/tui/components/UpdateBadge.js +29 -0
  31. package/dist/src/tui/components/UpdateOverlay.js +199 -0
  32. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  33. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  34. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  35. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  36. package/dist/src/tui/constants/keybindings.js +3 -0
  37. package/dist/src/tui/session-watcher.js +50 -0
  38. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  39. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  40. package/dist/src/tui/themes/dark.js +7 -0
  41. package/dist/src/tui/themes/dracula.js +7 -0
  42. package/dist/src/tui/themes/github-dark.js +7 -0
  43. package/dist/src/tui/themes/github-light.js +7 -0
  44. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  45. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  46. package/dist/src/tui/themes/light.js +7 -0
  47. package/dist/src/tui/themes/monokai.js +7 -0
  48. package/dist/src/tui/themes/nord.js +7 -0
  49. package/dist/src/tui/themes/one-dark.js +7 -0
  50. package/dist/src/tui/themes/rose-pine.js +7 -0
  51. package/dist/src/tui/themes/solarized-dark.js +7 -0
  52. package/dist/src/tui/themes/solarized-light.js +7 -0
  53. package/dist/src/tui/themes/tokyo-night.js +7 -0
  54. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  55. package/dist/src/tui/utils/staleDetection.js +51 -0
  56. package/dist/src/update/__tests__/cache.test.js +136 -0
  57. package/dist/src/update/__tests__/changelog.test.js +86 -0
  58. package/dist/src/update/__tests__/checker.test.js +148 -0
  59. package/dist/src/update/__tests__/index.test.js +37 -0
  60. package/dist/src/update/__tests__/installer.test.js +117 -0
  61. package/dist/src/update/__tests__/package-manager.test.js +73 -0
  62. package/dist/src/update/__tests__/version.test.js +74 -0
  63. package/dist/src/update/cache.js +74 -0
  64. package/dist/src/update/changelog.js +63 -0
  65. package/dist/src/update/checker.js +121 -0
  66. package/dist/src/update/index.js +15 -0
  67. package/dist/src/update/installer.js +51 -0
  68. package/dist/src/update/package-manager.js +49 -0
  69. package/dist/src/update/types.js +7 -0
  70. package/dist/src/update/version.js +114 -0
  71. package/package.json +1 -1
@@ -0,0 +1,300 @@
1
+ /**
2
+ * CLI Sessions Command — `auq sessions list`, `auq sessions show`, and `auq sessions dismiss`
3
+ * Manages listing, viewing, 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 Show ─────────────────────────────────────────────────
141
+ async function sessionsShow(args) {
142
+ const { flags, positionals } = parseFlags(args);
143
+ const jsonMode = flags.json === true;
144
+ const sessionId = positionals[0];
145
+ // ── Validate sessionId ──────────────────────────────────────────
146
+ if (!sessionId) {
147
+ outputResult({
148
+ success: false,
149
+ error: "Missing session ID. Usage: auq sessions show <sessionId> [--json]",
150
+ }, jsonMode);
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+ // ── Initialise SessionManager ───────────────────────────────────
155
+ const sessionManager = new SessionManager({
156
+ baseDir: getSessionDirectory(),
157
+ });
158
+ await sessionManager.initialize();
159
+ // ── Verify session exists ──────────────────────────────────────
160
+ const exists = await sessionManager.sessionExists(sessionId);
161
+ if (!exists) {
162
+ outputResult({ success: false, error: `Session not found: ${sessionId}` }, jsonMode);
163
+ process.exitCode = 1;
164
+ return;
165
+ }
166
+ // ── Fetch session data ──────────────────────────────────────────
167
+ const status = await sessionManager.getSessionStatus(sessionId);
168
+ const request = await sessionManager.getSessionRequest(sessionId);
169
+ const answersData = await sessionManager.getSessionAnswers(sessionId);
170
+ if (!status || !request) {
171
+ outputResult({ success: false, error: `Could not read session data for: ${sessionId}` }, jsonMode);
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+ const questions = request.questions;
176
+ const answers = answersData?.answers ?? null;
177
+ // ── Build answer lookup (questionIndex → UserAnswer) ────────────
178
+ const answerMap = new Map();
179
+ if (answers) {
180
+ for (const a of answers) {
181
+ answerMap.set(a.questionIndex, {
182
+ selectedOption: a.selectedOption,
183
+ selectedOptions: a.selectedOptions,
184
+ customText: a.customText,
185
+ });
186
+ }
187
+ }
188
+ // ── JSON output ─────────────────────────────────────────────────
189
+ if (jsonMode) {
190
+ const result = {
191
+ sessionId,
192
+ status: status.status,
193
+ createdAt: status.createdAt,
194
+ totalQuestions: questions.length,
195
+ questions: questions.map((q, i) => ({
196
+ index: i,
197
+ prompt: q.prompt,
198
+ title: q.title,
199
+ multiSelect: q.multiSelect ?? false,
200
+ options: q.options.map((o) => ({
201
+ label: o.label,
202
+ ...(o.description ? { description: o.description } : {}),
203
+ })),
204
+ })),
205
+ answers: answers
206
+ ? answers.map((a) => ({
207
+ questionIndex: a.questionIndex,
208
+ selectedOption: a.selectedOption ?? null,
209
+ selectedOptions: a.selectedOptions ?? null,
210
+ customText: a.customText ?? null,
211
+ timestamp: a.timestamp,
212
+ }))
213
+ : null,
214
+ };
215
+ console.log(JSON.stringify(result, null, 2));
216
+ return;
217
+ }
218
+ // ── Human-readable output ───────────────────────────────────────
219
+ const age = formatAge(status.createdAt);
220
+ console.log(`Session: ${sessionId}`);
221
+ console.log(`Status: ${status.status} | Created: ${age}`);
222
+ console.log(`Questions: ${questions.length}`);
223
+ console.log("");
224
+ for (let i = 0; i < questions.length; i++) {
225
+ const q = questions[i];
226
+ const selectTag = q.multiSelect ? "[multi-select]" : "[single-select]";
227
+ const answer = answerMap.get(i);
228
+ // Determine which options are selected
229
+ const selectedLabels = new Set();
230
+ if (answer) {
231
+ if (answer.selectedOption)
232
+ selectedLabels.add(answer.selectedOption);
233
+ if (answer.selectedOptions) {
234
+ for (const opt of answer.selectedOptions)
235
+ selectedLabels.add(opt);
236
+ }
237
+ }
238
+ console.log(` ${i + 1}. ${q.prompt} ${selectTag}`);
239
+ for (const opt of q.options) {
240
+ const prefix = selectedLabels.has(opt.label) ? "✓" : "→";
241
+ console.log(` ${prefix} ${opt.label}`);
242
+ if (opt.description) {
243
+ console.log(` ${opt.description}`);
244
+ }
245
+ }
246
+ // Show custom text if provided
247
+ if (answer?.customText) {
248
+ console.log(` ✎ Custom: ${answer.customText}`);
249
+ }
250
+ console.log("");
251
+ }
252
+ // ── Answer summary ──────────────────────────────────────────────
253
+ if (answers && answers.length > 0) {
254
+ const summaryParts = [];
255
+ for (const a of answers) {
256
+ if (a.selectedOption) {
257
+ summaryParts.push(a.selectedOption);
258
+ }
259
+ else if (a.selectedOptions && a.selectedOptions.length > 0) {
260
+ summaryParts.push(a.selectedOptions.join(", "));
261
+ }
262
+ else if (a.customText) {
263
+ summaryParts.push(`"${a.customText}"`);
264
+ }
265
+ }
266
+ if (summaryParts.length > 0) {
267
+ console.log(` (User answered: ${summaryParts.join(", ")})`);
268
+ }
269
+ }
270
+ }
271
+ // ── Sessions Command Dispatcher ────────────────────────────────────
272
+ export async function runSessionsCommand(args) {
273
+ const subcommand = args[0];
274
+ switch (subcommand) {
275
+ case "list":
276
+ return sessionsList(args.slice(1));
277
+ case "show":
278
+ return sessionsShow(args.slice(1));
279
+ case "dismiss":
280
+ return sessionsDismiss(args.slice(1));
281
+ default:
282
+ console.log("Usage: auq sessions <subcommand>", "\n");
283
+ console.log("Subcommands:");
284
+ console.log(" list [--pending|--stale|--all] [--json] List sessions");
285
+ console.log(" show <sessionId> [--json] Show session details");
286
+ console.log(" dismiss <sessionId> [--force] [--json] Dismiss/archive a session");
287
+ console.log("");
288
+ console.log("Examples:");
289
+ console.log(" auq sessions list");
290
+ console.log(" auq sessions list --stale --json");
291
+ console.log(" auq sessions show <sessionId>");
292
+ console.log(" auq sessions show <sessionId> --json");
293
+ console.log(" auq sessions dismiss <sessionId>");
294
+ console.log(" auq sessions dismiss <sessionId> --force");
295
+ if (subcommand !== undefined) {
296
+ process.exitCode = 1;
297
+ }
298
+ break;
299
+ }
300
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * CLI Update Command — `auq update`
3
+ *
4
+ * Checks for available updates, displays changelog, and installs
5
+ * the latest version using the detected package manager.
6
+ */
7
+ import { createInterface } from "node:readline";
8
+ import { UpdateChecker } from "../../update/checker.js";
9
+ import { fetchChangelog } from "../../update/changelog.js";
10
+ import { detectPackageManager } from "../../update/package-manager.js";
11
+ import { installUpdate, getManualCommand } from "../../update/installer.js";
12
+ import { parseFlags } from "../utils.js";
13
+ /**
14
+ * Prompt the user for input via readline.
15
+ *
16
+ * Uses stderr for the question text to keep stdout clean for piping.
17
+ */
18
+ function prompt(question) {
19
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
20
+ return new Promise((resolve) => {
21
+ rl.question(question, (answer) => {
22
+ rl.close();
23
+ resolve(answer);
24
+ });
25
+ });
26
+ }
27
+ /**
28
+ * Run the `auq update` command.
29
+ *
30
+ * Usage:
31
+ * auq update Check for updates and install interactively
32
+ * auq update -y Check and install without confirmation
33
+ * auq update --yes Same as -y
34
+ * auq update --json Output result as JSON
35
+ */
36
+ export async function runUpdateCommand(args) {
37
+ const { flags } = parseFlags(args);
38
+ const jsonMode = flags.json === true;
39
+ // parseFlags only handles --flag; check raw args for short -y flag
40
+ const skipPrompt = flags.yes === true || args.includes("-y");
41
+ // 1. Check for updates (blocking, with status output)
42
+ process.stderr.write("Checking for updates...\n");
43
+ const checker = new UpdateChecker();
44
+ let result;
45
+ try {
46
+ result = await checker.check();
47
+ }
48
+ catch {
49
+ const msg = "Unable to check for updates. Please check your network connection.";
50
+ if (jsonMode) {
51
+ console.log(JSON.stringify({ success: false, error: msg }, null, 2));
52
+ }
53
+ else {
54
+ process.stderr.write(`\u274c ${msg}\n`);
55
+ }
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ // 2. If no update available
60
+ if (!result) {
61
+ const version = checker["currentVersion"];
62
+ const msg = `Already up to date (v${version})`;
63
+ if (jsonMode) {
64
+ console.log(JSON.stringify({ success: true, upToDate: true, currentVersion: version }, null, 2));
65
+ }
66
+ else {
67
+ process.stderr.write(`\u2714 ${msg}\n`);
68
+ }
69
+ return;
70
+ }
71
+ // 3. Display update info
72
+ process.stderr.write(`\nUpdate available: ${result.currentVersion} \u2192 ${result.latestVersion} (${result.updateType})\n`);
73
+ // 4. Fetch and display changelog
74
+ const changelog = await fetchChangelog(result.latestVersion);
75
+ if (changelog.content) {
76
+ process.stderr.write(`\nChangelog:\n${changelog.content}\n`);
77
+ }
78
+ else {
79
+ process.stderr.write(`\nView changelog: ${changelog.fallbackUrl}\n`);
80
+ }
81
+ // 5. Breaking change warning for major updates
82
+ if (result.updateType === "major") {
83
+ process.stderr.write("\n\u26a0 Breaking changes may be included in this major version update.\n");
84
+ }
85
+ // 6. Confirmation prompt (unless --yes/-y)
86
+ if (!skipPrompt) {
87
+ const answer = await prompt("\nInstall update? (Y/n): ");
88
+ const trimmed = answer.trim().toLowerCase();
89
+ if (trimmed !== "" && trimmed !== "y" && trimmed !== "yes") {
90
+ process.stderr.write("Update cancelled.\n");
91
+ return;
92
+ }
93
+ }
94
+ // 7. Detect package manager and show what will run
95
+ const pm = detectPackageManager();
96
+ const manualCmd = getManualCommand(pm);
97
+ process.stderr.write(`\nInstalling with ${pm.name}: ${manualCmd}\n`);
98
+ // 8. Execute installation
99
+ const success = await installUpdate(pm);
100
+ if (success) {
101
+ const msg = "Update complete! Please restart auq.";
102
+ if (jsonMode) {
103
+ console.log(JSON.stringify({
104
+ success: true,
105
+ upToDate: false,
106
+ previousVersion: result.currentVersion,
107
+ installedVersion: result.latestVersion,
108
+ }, null, 2));
109
+ }
110
+ else {
111
+ process.stderr.write(`\u2705 ${msg}\n`);
112
+ }
113
+ }
114
+ else {
115
+ const msg = `Update failed. Run manually: ${manualCmd}`;
116
+ if (jsonMode) {
117
+ console.log(JSON.stringify({ success: false, error: "Installation failed", manualCommand: manualCmd }, null, 2));
118
+ }
119
+ else {
120
+ process.stderr.write(`\u274c ${msg}\n`);
121
+ }
122
+ process.exitCode = 1;
123
+ }
124
+ }
@@ -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
  });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AUQConfigSchema } from "../types.js";
3
+ import { DEFAULT_CONFIG } from "../defaults.js";
4
+ describe("updateCheck config", () => {
5
+ it("DEFAULT_CONFIG includes updateCheck: true", () => {
6
+ expect(DEFAULT_CONFIG.updateCheck).toBe(true);
7
+ });
8
+ it("schema accepts updateCheck: true", () => {
9
+ const result = AUQConfigSchema.parse({ updateCheck: true });
10
+ expect(result.updateCheck).toBe(true);
11
+ });
12
+ it("schema accepts updateCheck: false", () => {
13
+ const result = AUQConfigSchema.parse({ updateCheck: false });
14
+ expect(result.updateCheck).toBe(false);
15
+ });
16
+ it("schema defaults updateCheck to true when missing", () => {
17
+ const result = AUQConfigSchema.parse({});
18
+ expect(result.updateCheck).toBe(true);
19
+ });
20
+ it("partial schema retains default for updateCheck when not provided", () => {
21
+ const result = AUQConfigSchema.partial().parse({ maxOptions: 8 });
22
+ // Zod .default(true) still applies even when field is omitted in partial parse
23
+ expect(result.updateCheck).toBe(true);
24
+ });
25
+ it("schema rejects non-boolean updateCheck", () => {
26
+ expect(() => AUQConfigSchema.parse({ updateCheck: "yes" })).toThrow();
27
+ });
28
+ it("updateCheck coexists with other config values", () => {
29
+ const result = AUQConfigSchema.parse({ updateCheck: false });
30
+ // Other defaults should still be set
31
+ expect(result.updateCheck).toBe(false);
32
+ expect(result.maxOptions).toBeDefined();
33
+ });
34
+ });
@@ -8,8 +8,13 @@ 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,
14
17
  },
18
+ // Update
19
+ updateCheck: true,
15
20
  };
@@ -21,9 +21,15 @@ 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,
27
31
  sound: true,
28
32
  }),
33
+ // Update
34
+ updateCheck: z.boolean().default(true),
29
35
  });
@@ -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
  };