@micsushi/agent-hotline 0.5.3 → 1.0.1

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@micsushi/agent-hotline",
3
- "version": "0.5.3",
3
+ "version": "1.0.1",
4
4
  "description": "Local read-aloud hooks and tray app for AI coding agents.",
5
5
  "bin": {
6
6
  "ah": "packages/backend/bin/agent-hotline.js",
7
7
  "agent-hotline": "packages/backend/bin/agent-hotline.js",
8
- "agent-hotline-hook": "packages/backend/bin/agent-hotline-hook.js"
8
+ "agent-hotline-hook": "packages/backend/bin/agent-hotline-hook.js",
9
+ "agent-hotline-mcp": "packages/backend/bin/agent-hotline-mcp.js"
9
10
  },
10
11
  "files": [
11
12
  "README.md",
@@ -13,6 +14,7 @@
13
14
  "packages/backend/bin/",
14
15
  "packages/backend/src/",
15
16
  "packages/backend/skills/",
17
+ "packages/backend/web/",
16
18
  "packages/backend/package.json"
17
19
  ],
18
20
  "publishConfig": {
@@ -32,6 +34,8 @@
32
34
  "install-hook": "node scripts/install-hook.js",
33
35
  "install-skill": "node scripts/install-skill.js",
34
36
  "install-hotline": "node packages/backend/bin/agent-hotline.js install",
37
+ "stage-web": "node scripts/stage-web.mjs",
38
+ "prepack": "node scripts/stage-web.mjs",
35
39
  "test": "npm --prefix packages/backend test && npm --workspace @agent-hotline/desktop run test",
36
40
  "lint": "eslint . && npm run rust:clippy",
37
41
  "lint:fix": "eslint . --fix",
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../src/mcp-server.mjs").then(({ runStdioServer }) => runStdioServer());
@@ -7,14 +7,19 @@
7
7
  "bin": {
8
8
  "ah": "bin/agent-hotline.js",
9
9
  "agent-hotline": "bin/agent-hotline.js",
10
- "agent-hotline-hook": "bin/agent-hotline-hook.js"
10
+ "agent-hotline-hook": "bin/agent-hotline-hook.js",
11
+ "agent-hotline-mcp": "bin/agent-hotline-mcp.js"
11
12
  },
12
13
  "scripts": {
13
14
  "start": "node src/server.js",
14
- "test": "node --test test/*.test.js",
15
- "check": "node --check src/server.js && node --check src/settings-store.js && node --check src/speech-queue-store.js && node --check src/audio-cache-store.js && node --check src/installer.js && node --check src/hook-input-parser.js && node --check src/hook-command.js && node --check src/run-command.js && node --check src/speakable-filter.js && node --check bin/agent-hotline.js && node --check bin/agent-hotline-hook.js && node --check test/settings-store.test.js && node --check test/speech-queue-store.test.js && node --check test/audio-cache-store.test.js && node --check test/audio-endpoints.test.js && node --check test/installer.test.js && node --check test/run-command.test.js && node --check test/speakable-filter.test.js && node --check test/hook-input-parser.test.js && node --check test/hook-command.test.js && node --check test/server-endpoints.test.js"
15
+ "test": "node --test test/*.test.js test/*.test.mjs",
16
+ "check": "node --check src/server.js && node --check src/settings-store.js && node --check src/speech-queue-store.js && node --check src/audio-cache-store.js && node --check src/installer.js && node --check src/hook-input-parser.js && node --check src/hook-command.js && node --check src/run-command.js && node --check src/speakable-filter.js && node --check src/mcp-server.mjs && node --check bin/agent-hotline.js && node --check bin/agent-hotline-hook.js && node --check bin/agent-hotline-mcp.js && node --check test/settings-store.test.js && node --check test/speech-queue-store.test.js && node --check test/audio-cache-store.test.js && node --check test/audio-endpoints.test.js && node --check test/installer.test.js && node --check test/run-command.test.js && node --check test/speakable-filter.test.js && node --check test/hook-input-parser.test.js && node --check test/hook-command.test.js && node --check test/server-endpoints.test.js && node --check test/mcp-server.test.mjs"
16
17
  },
17
18
  "engines": {
18
19
  "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.29.0",
23
+ "zod": "^4.4.3"
19
24
  }
20
25
  }
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+
6
+ import { createRequire } from "node:module";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const { createAudioCacheStore } = require("./audio-cache-store.js");
11
+ const { createSettingsStore } = require("./settings-store.js");
12
+ const { createSpeechQueueStore } = require("./speech-queue-store.js");
13
+
14
+ function textResult(value) {
15
+ return {
16
+ content: [
17
+ {
18
+ type: "text",
19
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
20
+ }
21
+ ]
22
+ };
23
+ }
24
+
25
+ function sessionKeyOf(item) {
26
+ return item.sessionKey || item.threadId || `app:${item.sourceApp}`;
27
+ }
28
+
29
+ function projectKeyOf(item) {
30
+ if (!item.projectPath) return `direct:${item.sourceApp}`;
31
+ return String(item.projectPath).replace(/[\\/]/g, "").toLowerCase();
32
+ }
33
+
34
+ function projectLabelOf(item) {
35
+ if (!item.projectPath) return item.sourceApp || "Direct";
36
+ const raw = item.projectName || item.projectPath || "";
37
+ return (
38
+ String(raw)
39
+ .replace(/[\\/]+$/, "")
40
+ .split(/[\\/]/)
41
+ .pop() ||
42
+ item.sourceApp ||
43
+ "Direct"
44
+ );
45
+ }
46
+
47
+ function sessionLabelOf(item) {
48
+ const id = item.threadId ? item.threadId.slice(0, 8) : "";
49
+ if (item.sessionName && id) return `${item.sessionName} - ${id}`;
50
+ if (item.sessionName) return item.sessionName;
51
+ if (id) return id;
52
+ return `${item.sourceApp} - direct`;
53
+ }
54
+
55
+ function visibleItems(queueStore) {
56
+ return queueStore.getState().items.filter((item) => item.speakableText && !item.trashedAt);
57
+ }
58
+
59
+ function trashedItems(queueStore) {
60
+ return queueStore.getState().items.filter((item) => item.speakableText && item.trashedAt);
61
+ }
62
+
63
+ function compactItem(item) {
64
+ return {
65
+ id: item.id,
66
+ sourceApp: item.sourceApp,
67
+ projectKey: projectKeyOf(item),
68
+ projectName: projectLabelOf(item),
69
+ sessionKey: sessionKeyOf(item),
70
+ sessionName: sessionLabelOf(item),
71
+ threadId: item.threadId || null,
72
+ createdAt: item.timestamps?.createdAt || null,
73
+ status: item.status,
74
+ trashedAt: item.trashedAt || null,
75
+ preview: String(item.speakableText || "")
76
+ .replace(/\s+/g, " ")
77
+ .trim()
78
+ .slice(0, 220)
79
+ };
80
+ }
81
+
82
+ function groupItems(items) {
83
+ const projects = new Map();
84
+ for (const item of items) {
85
+ const projectKey = projectKeyOf(item);
86
+ if (!projects.has(projectKey)) {
87
+ projects.set(projectKey, {
88
+ key: projectKey,
89
+ label: projectLabelOf(item),
90
+ sessions: new Map(),
91
+ itemCount: 0
92
+ });
93
+ }
94
+ const project = projects.get(projectKey);
95
+ project.itemCount += 1;
96
+ const sessionKey = sessionKeyOf(item);
97
+ if (!project.sessions.has(sessionKey)) {
98
+ project.sessions.set(sessionKey, {
99
+ key: sessionKey,
100
+ label: sessionLabelOf(item),
101
+ itemCount: 0,
102
+ latestAt: ""
103
+ });
104
+ }
105
+ const session = project.sessions.get(sessionKey);
106
+ session.itemCount += 1;
107
+ session.latestAt = item.timestamps?.createdAt || session.latestAt;
108
+ }
109
+ return [...projects.values()].map((project) => ({
110
+ ...project,
111
+ sessions: [...project.sessions.values()]
112
+ }));
113
+ }
114
+
115
+ function normalize(value) {
116
+ return String(value || "")
117
+ .replace(/\s+/g, " ")
118
+ .toLowerCase();
119
+ }
120
+
121
+ function searchItems(items, query) {
122
+ const terms = normalize(query).split(/\s+/).filter(Boolean);
123
+ if (terms.length === 0) return [];
124
+ return items
125
+ .filter((item) => {
126
+ const haystack = normalize(
127
+ [
128
+ item.id,
129
+ item.threadId,
130
+ item.threadLabel,
131
+ item.sessionName,
132
+ projectLabelOf(item),
133
+ sessionLabelOf(item),
134
+ item.speakableText,
135
+ ...(Array.isArray(item.userMessages) ? item.userMessages : [])
136
+ ].join(" ")
137
+ );
138
+ return terms.every((term) => haystack.includes(term));
139
+ })
140
+ .map(compactItem);
141
+ }
142
+
143
+ function queueTargetSchema() {
144
+ return {
145
+ itemIds: z.array(z.string()).optional(),
146
+ projectKey: z.string().optional(),
147
+ sessionKey: z.string().optional(),
148
+ all: z.boolean().optional()
149
+ };
150
+ }
151
+
152
+ function applyTarget(queueStore, args, mode) {
153
+ if (Array.isArray(args.itemIds)) {
154
+ return mode === "trash"
155
+ ? queueStore.trashItems(args.itemIds)
156
+ : queueStore.restoreItems(args.itemIds);
157
+ }
158
+ if (args.projectKey) {
159
+ return mode === "trash"
160
+ ? queueStore.trashByProject(args.projectKey)
161
+ : queueStore.restoreByProject(args.projectKey);
162
+ }
163
+ if (args.sessionKey) {
164
+ return mode === "trash"
165
+ ? queueStore.trashBySession(args.sessionKey)
166
+ : queueStore.restoreBySession(args.sessionKey);
167
+ }
168
+ if (args.all === true) {
169
+ return mode === "trash" ? queueStore.trashAll() : queueStore.restoreAll();
170
+ }
171
+ throw new Error("Provide itemIds, projectKey, sessionKey, or all=true.");
172
+ }
173
+
174
+ function idsByProject(queueStore, projectKey) {
175
+ return visibleItems(queueStore)
176
+ .filter((item) => projectKeyOf(item) === projectKey)
177
+ .map((item) => item.id);
178
+ }
179
+
180
+ function idsBySession(queueStore, sessionKey) {
181
+ return visibleItems(queueStore)
182
+ .filter((item) => sessionKeyOf(item) === sessionKey)
183
+ .map((item) => item.id);
184
+ }
185
+
186
+ function registerAgentHotlineTools(server, { queueStore, settingsStore, audioCacheStore }) {
187
+ server.registerTool(
188
+ "get_state",
189
+ {
190
+ title: "Get Agent Hotline State",
191
+ description: "Return settings plus compact visible and trashed chat counts."
192
+ },
193
+ async () =>
194
+ textResult({
195
+ settings: settingsStore.load(),
196
+ visibleCount: visibleItems(queueStore).length,
197
+ trashedCount: trashedItems(queueStore).length,
198
+ pending: queueStore.getPending().map(compactItem),
199
+ current: queueStore.getCurrent() ? compactItem(queueStore.getCurrent()) : null,
200
+ latest: queueStore.getLatest() ? compactItem(queueStore.getLatest()) : null
201
+ })
202
+ );
203
+
204
+ server.registerTool(
205
+ "list_projects",
206
+ {
207
+ title: "List Projects",
208
+ description: "List visible Agent Hotline projects and sessions."
209
+ },
210
+ async () => textResult(groupItems(visibleItems(queueStore)))
211
+ );
212
+
213
+ server.registerTool(
214
+ "list_messages",
215
+ {
216
+ title: "List Messages",
217
+ description: "List visible messages, optionally scoped to a project/session.",
218
+ inputSchema: {
219
+ projectKey: z.string().optional(),
220
+ sessionKey: z.string().optional(),
221
+ limit: z.number().int().min(1).max(200).default(50)
222
+ }
223
+ },
224
+ async ({ projectKey, sessionKey, limit }) => {
225
+ let items = visibleItems(queueStore);
226
+ if (projectKey) items = items.filter((item) => projectKeyOf(item) === projectKey);
227
+ if (sessionKey) items = items.filter((item) => sessionKeyOf(item) === sessionKey);
228
+ return textResult(items.slice(-limit).map(compactItem));
229
+ }
230
+ );
231
+
232
+ server.registerTool(
233
+ "read_message",
234
+ {
235
+ title: "Read Message",
236
+ description: "Read one full visible or trashed message by id.",
237
+ inputSchema: { itemId: z.string() }
238
+ },
239
+ async ({ itemId }) => {
240
+ const item = queueStore.getState().items.find((entry) => entry.id === itemId);
241
+ if (!item) throw new Error(`Message not found: ${itemId}`);
242
+ return textResult({
243
+ ...compactItem(item),
244
+ speakableText: item.speakableText,
245
+ userMessages: item.userMessages || []
246
+ });
247
+ }
248
+ );
249
+
250
+ server.registerTool(
251
+ "search_messages",
252
+ {
253
+ title: "Search Messages",
254
+ description:
255
+ "Search visible messages by keyword, project, session, id, prompt, or spoken text.",
256
+ inputSchema: { query: z.string().min(1), limit: z.number().int().min(1).max(100).default(25) }
257
+ },
258
+ async ({ query, limit }) =>
259
+ textResult(searchItems(visibleItems(queueStore), query).slice(0, limit))
260
+ );
261
+
262
+ server.registerTool(
263
+ "list_trash",
264
+ {
265
+ title: "List Trash",
266
+ description: "List trashed Agent Hotline projects, sessions, and messages."
267
+ },
268
+ async () =>
269
+ textResult({
270
+ groups: groupItems(trashedItems(queueStore)),
271
+ messages: trashedItems(queueStore).map(compactItem)
272
+ })
273
+ );
274
+
275
+ server.registerTool(
276
+ "move_to_trash",
277
+ {
278
+ title: "Move Chats To Trash",
279
+ description: "Move messages/projects/sessions to trash and delete their saved audio.",
280
+ inputSchema: queueTargetSchema()
281
+ },
282
+ async (args) => {
283
+ const ids = applyTarget(queueStore, args, "trash");
284
+ const removedAudio = audioCacheStore.removeByItemIds(ids);
285
+ return textResult({ trashed: ids, removedAudio });
286
+ }
287
+ );
288
+
289
+ server.registerTool(
290
+ "restore_from_trash",
291
+ {
292
+ title: "Restore Chats From Trash",
293
+ description: "Restore trashed messages/projects/sessions.",
294
+ inputSchema: queueTargetSchema()
295
+ },
296
+ async (args) => textResult({ restored: applyTarget(queueStore, args, "restore") })
297
+ );
298
+
299
+ server.registerTool(
300
+ "list_audio_cache",
301
+ {
302
+ title: "List Audio Cache",
303
+ description: "List saved audio recordings."
304
+ },
305
+ async () => textResult(audioCacheStore.list())
306
+ );
307
+
308
+ server.registerTool(
309
+ "delete_audio_cache",
310
+ {
311
+ title: "Delete Audio Cache",
312
+ description: "Delete saved audio only, leaving chats visible.",
313
+ inputSchema: {
314
+ itemId: z.string().optional(),
315
+ projectKey: z.string().optional(),
316
+ sessionKey: z.string().optional(),
317
+ all: z.boolean().optional()
318
+ }
319
+ },
320
+ async ({ itemId, projectKey, sessionKey, all }) => {
321
+ let removed;
322
+ if (itemId) removed = audioCacheStore.removeOne(itemId);
323
+ else if (projectKey)
324
+ removed = audioCacheStore.removeByItemIds(idsByProject(queueStore, projectKey));
325
+ else if (sessionKey)
326
+ removed = audioCacheStore.removeByItemIds(idsBySession(queueStore, sessionKey));
327
+ else if (all === true) removed = audioCacheStore.clearAll();
328
+ else throw new Error("Provide itemId, projectKey, sessionKey, or all=true.");
329
+ return textResult({ removed });
330
+ }
331
+ );
332
+
333
+ server.registerTool(
334
+ "get_settings",
335
+ {
336
+ title: "Get Settings",
337
+ description: "Read Agent Hotline settings."
338
+ },
339
+ async () => textResult(settingsStore.load())
340
+ );
341
+
342
+ server.registerTool(
343
+ "update_settings",
344
+ {
345
+ title: "Update Settings",
346
+ description: "Update safe Agent Hotline settings.",
347
+ inputSchema: {
348
+ readBehavior: z.enum(["manual", "auto"]).optional(),
349
+ mute: z.boolean().optional(),
350
+ engine: z.enum(["webview", "kokoro", "kokoro-ts"]).optional(),
351
+ voice: z.string().optional(),
352
+ audioOutputDeviceId: z.string().optional(),
353
+ kokoroVoice: z.string().optional(),
354
+ rate: z.number().min(0.1).max(10).optional(),
355
+ volume: z.number().min(0).max(1).optional(),
356
+ notifyOnNewReply: z.boolean().optional(),
357
+ notificationOpens: z.enum(["full", "mini"]).optional(),
358
+ highlightSpokenText: z.boolean().optional(),
359
+ audioCacheLimitMb: z.number().min(10).max(100000).optional()
360
+ }
361
+ },
362
+ async (args) => textResult(settingsStore.update(args))
363
+ );
364
+ }
365
+
366
+ export async function createAgentHotlineMcpServer(options = {}) {
367
+ const settingsStore = options.settingsStore || createSettingsStore({ dataDir: options.dataDir });
368
+ const queueStore = options.queueStore || createSpeechQueueStore({ dataDir: options.dataDir });
369
+ const audioCacheStore =
370
+ options.audioCacheStore ||
371
+ createAudioCacheStore({
372
+ dataDir: options.dataDir,
373
+ getMaxBytes: () => Number(settingsStore.load().audioCacheLimitMb) * 1024 * 1024
374
+ });
375
+ const server = new McpServer({ name: "agent-hotline", version: "0.1.0" });
376
+ registerAgentHotlineTools(server, { queueStore, settingsStore, audioCacheStore });
377
+ return server;
378
+ }
379
+
380
+ export async function runStdioServer() {
381
+ const server = await createAgentHotlineMcpServer();
382
+ await server.connect(new StdioServerTransport());
383
+ }
384
+
385
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
386
+ runStdioServer().catch((error) => {
387
+ console.error(error);
388
+ process.exit(1);
389
+ });
390
+ }
@@ -280,7 +280,7 @@ function sendBinary(res, status, buffer, contentType) {
280
280
  }
281
281
 
282
282
  function sessionKeyForItem(item) {
283
- return item.threadId || `app:${item.sourceApp}`;
283
+ return item.sessionKey || item.threadId || `app:${item.sourceApp}`;
284
284
  }
285
285
 
286
286
  // Stable project identity from a (possibly inconsistently-shaped) path. Stripping
@@ -305,6 +305,7 @@ function validateSettingsPatch(patch) {
305
305
  "mute",
306
306
  "engine",
307
307
  "voice",
308
+ "audioOutputDeviceId",
308
309
  "kokoroVoice",
309
310
  "rate",
310
311
  "volume",
@@ -339,6 +340,9 @@ function validateSettingsPatch(patch) {
339
340
  errors.push("engine must be webview or kokoro");
340
341
  }
341
342
  if ("voice" in patch && typeof patch.voice !== "string") errors.push("voice must be string");
343
+ if ("audioOutputDeviceId" in patch && typeof patch.audioOutputDeviceId !== "string") {
344
+ errors.push("audioOutputDeviceId must be string");
345
+ }
342
346
  if ("kokoroVoice" in patch && typeof patch.kokoroVoice !== "string") {
343
347
  errors.push("kokoroVoice must be string");
344
348
  }
@@ -410,10 +414,117 @@ function queueState(queueStore) {
410
414
  };
411
415
  }
412
416
 
417
+ function visibleQueueItems(queueStore) {
418
+ return queueStore.getState().items.filter((item) => !item.trashedAt);
419
+ }
420
+
421
+ function trashedQueueItems(queueStore) {
422
+ return queueStore.getState().items.filter((item) => item.trashedAt);
423
+ }
424
+
425
+ function queueItemIdsByProject(queueStore, projectKey, { trashed = false } = {}) {
426
+ const items = trashed ? trashedQueueItems(queueStore) : visibleQueueItems(queueStore);
427
+ return items.filter((item) => projectKeyForItem(item) === projectKey).map((item) => item.id);
428
+ }
429
+
430
+ function queueItemIdsBySession(queueStore, sessionKey, { trashed = false } = {}) {
431
+ const items = trashed ? trashedQueueItems(queueStore) : visibleQueueItems(queueStore);
432
+ return items.filter((item) => sessionKeyForItem(item) === sessionKey).map((item) => item.id);
433
+ }
434
+
435
+ function applyQueueTarget(queueStore, body, mode) {
436
+ requirePlainObject(body, "Queue target must be a JSON object");
437
+ if (Array.isArray(body.itemIds)) {
438
+ return mode === "trash"
439
+ ? queueStore.trashItems(body.itemIds)
440
+ : queueStore.restoreItems(body.itemIds);
441
+ }
442
+ if (typeof body.projectKey === "string" && body.projectKey.trim()) {
443
+ return mode === "trash"
444
+ ? queueStore.trashByProject(body.projectKey)
445
+ : queueStore.restoreByProject(body.projectKey);
446
+ }
447
+ if (typeof body.sessionKey === "string" && body.sessionKey.trim()) {
448
+ return mode === "trash"
449
+ ? queueStore.trashBySession(body.sessionKey)
450
+ : queueStore.restoreBySession(body.sessionKey);
451
+ }
452
+ if (body.all === true) {
453
+ return mode === "trash" ? queueStore.trashAll() : queueStore.restoreAll();
454
+ }
455
+ throw createHttpError(
456
+ 400,
457
+ "invalid_request",
458
+ "Provide itemIds, projectKey, sessionKey, or all=true"
459
+ );
460
+ }
461
+
413
462
  function getPathname(req) {
414
463
  return new URL(req.url, "http://127.0.0.1").pathname;
415
464
  }
416
465
 
466
+ // Built desktop UI, served so `agent-hotline run` opens the full app in a
467
+ // browser. The npm package ships a pruned copy under web/ (no heavy local-TTS
468
+ // assets); a source checkout uses the full desktop/dist build. Absent in either
469
+ // case, routes fall back to the inline page() console.
470
+ const WEB_CANDIDATES = [
471
+ path.resolve(__dirname, "../web"),
472
+ path.resolve(__dirname, "../../desktop/dist")
473
+ ];
474
+
475
+ function webDir() {
476
+ for (const dir of WEB_CANDIDATES) {
477
+ try {
478
+ if (fs.statSync(path.join(dir, "index.html")).isFile()) return dir;
479
+ } catch {
480
+ // try next candidate
481
+ }
482
+ }
483
+ return null;
484
+ }
485
+
486
+ const STATIC_CONTENT_TYPES = {
487
+ ".html": "text/html; charset=utf-8",
488
+ ".js": "text/javascript; charset=utf-8",
489
+ ".mjs": "text/javascript; charset=utf-8",
490
+ ".css": "text/css; charset=utf-8",
491
+ ".json": "application/json; charset=utf-8",
492
+ ".svg": "image/svg+xml",
493
+ ".png": "image/png",
494
+ ".jpg": "image/jpeg",
495
+ ".jpeg": "image/jpeg",
496
+ ".webp": "image/webp",
497
+ ".ico": "image/x-icon",
498
+ ".woff": "font/woff",
499
+ ".woff2": "font/woff2",
500
+ ".ttf": "font/ttf",
501
+ ".wasm": "application/wasm",
502
+ ".map": "application/json; charset=utf-8"
503
+ };
504
+
505
+ // Resolve a request path to a file inside the active web dir, refusing anything
506
+ // that escapes the directory. Returns the absolute path or null.
507
+ function resolveStaticFile(pathname) {
508
+ const root = webDir();
509
+ if (!root) return null;
510
+ const rel = decodeURIComponent(pathname).replace(/^\/+/, "");
511
+ const target = path.resolve(root, rel);
512
+ if (target !== root && !target.startsWith(root + path.sep)) return null;
513
+ try {
514
+ if (fs.statSync(target).isFile()) return target;
515
+ } catch {
516
+ return null;
517
+ }
518
+ return null;
519
+ }
520
+
521
+ function serveStaticFile(res, filePath) {
522
+ const type =
523
+ STATIC_CONTENT_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
524
+ res.writeHead(200, { "Content-Type": type });
525
+ fs.createReadStream(filePath).pipe(res);
526
+ }
527
+
417
528
  function page() {
418
529
  return `<!doctype html>
419
530
  <html lang="en">
@@ -422,12 +533,21 @@ function page() {
422
533
  <meta name="viewport" content="width=device-width, initial-scale=1">
423
534
  <title>Agent Hotline</title>
424
535
  <style>
425
- :root { color-scheme: light dark; font-family: Inter, Segoe UI, system-ui, sans-serif; }
536
+ :root {
537
+ color-scheme: light dark;
538
+ --font-ui: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
539
+ --type-body: 0.8125rem;
540
+ --type-ui: 0.875rem;
541
+ --type-title: 1.875rem;
542
+ --line-copy: 1.45;
543
+ font-family: var(--font-ui);
544
+ }
545
+ body { font-size: var(--type-body); }
426
546
  body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #101418; color: #eef2f4; }
427
547
  main { width: min(860px, calc(100vw - 32px)); }
428
548
  .panel { border: 1px solid #2b343c; border-radius: 8px; padding: 24px; background: #171d22; box-shadow: 0 20px 60px rgba(0,0,0,.25); }
429
- .stage { color: #8fb7ff; font-size: 14px; margin-bottom: 16px; }
430
- h1 { font-size: 30px; line-height: 1.2; margin: 0 0 16px; letter-spacing: 0; }
549
+ .stage { color: #8fb7ff; font-size: var(--type-ui); margin-bottom: 16px; }
550
+ h1 { font-size: var(--type-title); line-height: 1.2; margin: 0 0 16px; letter-spacing: 0; }
431
551
  .rec { color: #c7d0d8; border-left: 3px solid #58c48d; padding-left: 12px; margin: 18px 0; }
432
552
  textarea { width: 100%; min-height: 130px; box-sizing: border-box; border-radius: 6px; border: 1px solid #34404a; background: #0f1418; color: #eef2f4; padding: 12px; font: inherit; resize: vertical; }
433
553
  .row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; align-items: center; }
@@ -436,7 +556,7 @@ function page() {
436
556
  button:disabled { opacity: .5; cursor: not-allowed; }
437
557
  a { color: #9fc4ff; text-decoration: none; }
438
558
  a:hover { text-decoration: underline; }
439
- .meta { color: #9ba8b2; font-size: 13px; margin-top: 16px; }
559
+ .meta { color: #9ba8b2; font-size: var(--type-body); margin-top: 16px; }
440
560
  .done { color: #8ee6b0; }
441
561
  </style>
442
562
  </head>
@@ -576,11 +696,25 @@ function createServer(options = {}) {
576
696
  const pathname = getPathname(req);
577
697
 
578
698
  if (req.method === "GET" && pathname === "/") {
699
+ const index = resolveStaticFile("/index.html");
700
+ if (index) {
701
+ serveStaticFile(res, index);
702
+ return;
703
+ }
579
704
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
580
705
  res.end(page());
581
706
  return;
582
707
  }
583
708
 
709
+ // Tell the browser-served UI to call this same origin for the API, so a
710
+ // custom --port still works. The bundled static config.json (used by the
711
+ // packaged desktop app) is overridden here only for HTTP requests.
712
+ if (req.method === "GET" && pathname === "/config.json") {
713
+ const host = req.headers.host || `${HOST}:${PORT}`;
714
+ sendJson(res, 200, { backendUrl: `http://${host}` });
715
+ return;
716
+ }
717
+
584
718
  if (req.method === "GET" && (pathname === "/health" || pathname === "/api/health")) {
585
719
  sendJson(res, 200, { ok: true, service: "agent-hotline", host: HOST });
586
720
  return;
@@ -626,6 +760,7 @@ function createServer(options = {}) {
626
760
  rawSource: body.rawSource,
627
761
  speakableText: body.speakableText,
628
762
  sourceApp: body.sourceApp,
763
+ sessionKey: body.sessionKey,
629
764
  threadId: body.threadId,
630
765
  threadLabel: body.threadLabel,
631
766
  sessionName: body.sessionName,
@@ -670,6 +805,21 @@ function createServer(options = {}) {
670
805
  return;
671
806
  }
672
807
 
808
+ if (req.method === "POST" && pathname === "/api/queue/trash") {
809
+ const body = await readJsonBody(req);
810
+ const ids = applyQueueTarget(queueStore, body, "trash");
811
+ const removedAudio = audioCacheStore.removeByItemIds(ids);
812
+ sendJson(res, 200, { trashed: ids, removedAudio, queue: queueState(queueStore) });
813
+ return;
814
+ }
815
+
816
+ if (req.method === "POST" && pathname === "/api/queue/restore") {
817
+ const body = await readJsonBody(req);
818
+ const ids = applyQueueTarget(queueStore, body, "restore");
819
+ sendJson(res, 200, { restored: ids, queue: queueState(queueStore) });
820
+ return;
821
+ }
822
+
673
823
  const replayMatch = pathname.match(/^\/api\/queue\/([^/]+)\/replay$/);
674
824
  if (req.method === "POST" && replayMatch) {
675
825
  const item = queueStore.replayItem(decodeURIComponent(replayMatch[1]));
@@ -728,33 +878,36 @@ function createServer(options = {}) {
728
878
 
729
879
  if (req.method === "GET" && pathname === "/api/audio-cache") {
730
880
  const { entries, totalBytes, maxBytes } = audioCacheStore.list();
731
- const itemsById = new Map(queueStore.getState().items.map((item) => [item.id, item]));
732
- const enriched = entries.map((entry) => {
881
+ const itemsById = new Map(visibleQueueItems(queueStore).map((item) => [item.id, item]));
882
+ const enriched = entries.flatMap((entry) => {
733
883
  const item = itemsById.get(entry.itemId);
734
- return {
735
- itemId: entry.itemId,
736
- engine: entry.engine,
737
- voice: entry.voice,
738
- bytes: entry.bytes,
739
- durationSec: entry.durationSec,
740
- wordAccurate: entry.wordAccurate,
741
- createdAt: entry.createdAt,
742
- lastAccessedAt: entry.lastAccessedAt,
743
- sourceApp: item ? item.sourceApp : null,
744
- threadId: item ? item.threadId || null : null,
745
- sessionName: item ? item.sessionName || item.threadLabel || null : null,
746
- sessionKey: item ? sessionKeyForItem(item) : "app:unknown",
747
- projectPath: item ? item.projectPath || null : null,
748
- projectName: item ? item.projectName || null : null,
749
- projectKey: item ? projectKeyForItem(item) : "direct:unknown",
750
- itemCreatedAt: item ? item.timestamps && item.timestamps.createdAt : null,
751
- preview: item
752
- ? String(item.speakableText || "")
753
- .replace(/\s+/g, " ")
754
- .trim()
755
- .slice(0, 80)
756
- : ""
757
- };
884
+ if (!item) return [];
885
+ return [
886
+ {
887
+ itemId: entry.itemId,
888
+ engine: entry.engine,
889
+ voice: entry.voice,
890
+ bytes: entry.bytes,
891
+ durationSec: entry.durationSec,
892
+ wordAccurate: entry.wordAccurate,
893
+ createdAt: entry.createdAt,
894
+ lastAccessedAt: entry.lastAccessedAt,
895
+ sourceApp: item.sourceApp,
896
+ threadId: item.threadId || null,
897
+ sessionName: item.sessionName || item.threadLabel || null,
898
+ sessionKey: sessionKeyForItem(item),
899
+ projectPath: item.projectPath || null,
900
+ projectName: item.projectName || null,
901
+ projectKey: projectKeyForItem(item),
902
+ itemCreatedAt: item.timestamps && item.timestamps.createdAt,
903
+ preview: item
904
+ ? String(item.speakableText || "")
905
+ .replace(/\s+/g, " ")
906
+ .trim()
907
+ .slice(0, 80)
908
+ : ""
909
+ }
910
+ ];
758
911
  });
759
912
  sendJson(res, 200, { entries: enriched, totalBytes, maxBytes });
760
913
  return;
@@ -777,19 +930,13 @@ function createServer(options = {}) {
777
930
  }
778
931
  const session = query.get("session");
779
932
  if (session) {
780
- const ids = queueStore
781
- .getState()
782
- .items.filter((item) => sessionKeyForItem(item) === session)
783
- .map((item) => item.id);
933
+ const ids = queueItemIdsBySession(queueStore, session);
784
934
  sendJson(res, 200, { removed: audioCacheStore.removeByItemIds(ids) });
785
935
  return;
786
936
  }
787
937
  const project = query.get("project");
788
938
  if (project) {
789
- const ids = queueStore
790
- .getState()
791
- .items.filter((item) => projectKeyForItem(item) === project)
792
- .map((item) => item.id);
939
+ const ids = queueItemIdsByProject(queueStore, project);
793
940
  sendJson(res, 200, { removed: audioCacheStore.removeByItemIds(ids) });
794
941
  return;
795
942
  }
@@ -830,6 +977,16 @@ function createServer(options = {}) {
830
977
  return;
831
978
  }
832
979
 
980
+ // Static assets for the built UI (JS/CSS/fonts/etc). API paths never reach
981
+ // here, so this only serves the desktop dist bundle.
982
+ if (req.method === "GET" && !pathname.startsWith("/api/")) {
983
+ const file = resolveStaticFile(pathname);
984
+ if (file) {
985
+ serveStaticFile(res, file);
986
+ return;
987
+ }
988
+ }
989
+
833
990
  throw createHttpError(404, "not_found", "Not found");
834
991
  } catch (error) {
835
992
  if (!error.status && /Queue item not found/.test(error.message)) {
@@ -14,6 +14,7 @@ const DEFAULT_SETTINGS = Object.freeze({
14
14
  mute: false,
15
15
  engine: "webview",
16
16
  voice: "",
17
+ audioOutputDeviceId: "",
17
18
  kokoroVoice: "af_heart",
18
19
  rate: 0.92,
19
20
  volume: 1,
@@ -90,6 +91,7 @@ function normalizeSettings(input) {
90
91
  mute: booleanOrDefault(source.mute, defaults.mute),
91
92
  engine: TTS_ENGINES.has(source.engine) ? source.engine : defaults.engine,
92
93
  voice: stringOrDefault(source.voice, defaults.voice),
94
+ audioOutputDeviceId: stringOrDefault(source.audioOutputDeviceId, defaults.audioOutputDeviceId),
93
95
  kokoroVoice: stringOrDefault(source.kokoroVoice, defaults.kokoroVoice),
94
96
  rate: numberInRangeOrDefault(source.rate, defaults.rate, 0.1, 10),
95
97
  volume: numberInRangeOrDefault(source.volume, defaults.volume, 0, 1),
@@ -144,6 +144,46 @@ function createSpeechQueueStore(options = {}) {
144
144
  return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
145
145
  }
146
146
 
147
+ function isVisible(item) {
148
+ return !item.trashedAt;
149
+ }
150
+
151
+ function sessionKeyForItem(item) {
152
+ return item.sessionKey || item.threadId || `app:${item.sourceApp}`;
153
+ }
154
+
155
+ function projectKeyForItem(item) {
156
+ if (!item.projectPath) return `direct:${item.sourceApp}`;
157
+ return String(item.projectPath).replace(/[\\/]/g, "").toLowerCase();
158
+ }
159
+
160
+ function trashMatching(predicate) {
161
+ const timestamp = now();
162
+ const ids = [];
163
+ for (const item of state.items) {
164
+ if (item.trashedAt || !predicate(item)) continue;
165
+ item.trashedAt = timestamp;
166
+ touch(item, timestamp);
167
+ ids.push(item.id);
168
+ }
169
+ if (ids.length) persist();
170
+ return ids;
171
+ }
172
+
173
+ function restoreMatching(predicate) {
174
+ const timestamp = now();
175
+ const ids = [];
176
+ for (const item of state.items) {
177
+ if (!item.trashedAt || !predicate(item)) continue;
178
+ delete item.trashedAt;
179
+ item.timestamps.restoredAt = timestamp;
180
+ touch(item, timestamp);
181
+ ids.push(item.id);
182
+ }
183
+ if (ids.length) persist();
184
+ return ids;
185
+ }
186
+
147
187
  function enqueue(input) {
148
188
  const timestamp = now();
149
189
  const item = {
@@ -160,6 +200,8 @@ function createSpeechQueueStore(options = {}) {
160
200
 
161
201
  const threadId = optionalString(input.threadId);
162
202
  if (threadId) item.threadId = threadId;
203
+ const sessionKey = optionalString(input.sessionKey);
204
+ if (sessionKey) item.sessionKey = sessionKey;
163
205
  const threadLabel = optionalString(input.threadLabel);
164
206
  if (threadLabel) item.threadLabel = threadLabel;
165
207
  const sessionName = optionalString(input.sessionName);
@@ -181,16 +223,17 @@ function createSpeechQueueStore(options = {}) {
181
223
  }
182
224
 
183
225
  function getPending() {
184
- return clone(state.items.filter((item) => item.status === "pending"));
226
+ return clone(state.items.filter((item) => isVisible(item) && item.status === "pending"));
185
227
  }
186
228
 
187
229
  function getCurrent() {
188
- const current = state.items.find((item) => item.status === "playing") || null;
230
+ const current =
231
+ state.items.find((item) => isVisible(item) && item.status === "playing") || null;
189
232
  return current ? clone(current) : null;
190
233
  }
191
234
 
192
235
  function getLatest() {
193
- const latest = state.items[state.items.length - 1] || null;
236
+ const latest = [...state.items].reverse().find(isVisible) || null;
194
237
  return latest ? clone(latest) : null;
195
238
  }
196
239
 
@@ -249,6 +292,7 @@ function createSpeechQueueStore(options = {}) {
249
292
  }
250
293
  };
251
294
  if (source.threadId) item.threadId = source.threadId;
295
+ if (source.sessionKey) item.sessionKey = source.sessionKey;
252
296
  if (source.threadLabel) item.threadLabel = source.threadLabel;
253
297
  if (source.sessionName) item.sessionName = source.sessionName;
254
298
  if (source.projectPath) item.projectPath = source.projectPath;
@@ -265,18 +309,52 @@ function createSpeechQueueStore(options = {}) {
265
309
  function replayLatest() {
266
310
  const latest = [...state.items]
267
311
  .reverse()
268
- .find((item) => item.speakableText && item.status !== "skipped");
312
+ .find((item) => isVisible(item) && item.speakableText && item.status !== "skipped");
269
313
  return latest ? pushReplay(latest) : null;
270
314
  }
271
315
 
272
316
  function replayItem(id) {
273
317
  const source = findItem(id);
274
- if (!source || !source.speakableText) {
318
+ if (!source || source.trashedAt || !source.speakableText) {
275
319
  return null;
276
320
  }
277
321
  return pushReplay(source);
278
322
  }
279
323
 
324
+ function trashItems(ids) {
325
+ const idSet = new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string"));
326
+ return trashMatching((item) => idSet.has(item.id));
327
+ }
328
+
329
+ function trashByProject(projectKey) {
330
+ return trashMatching((item) => projectKeyForItem(item) === projectKey);
331
+ }
332
+
333
+ function trashBySession(sessionKey) {
334
+ return trashMatching((item) => sessionKeyForItem(item) === sessionKey);
335
+ }
336
+
337
+ function trashAll() {
338
+ return trashMatching(() => true);
339
+ }
340
+
341
+ function restoreItems(ids) {
342
+ const idSet = new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string"));
343
+ return restoreMatching((item) => idSet.has(item.id));
344
+ }
345
+
346
+ function restoreByProject(projectKey) {
347
+ return restoreMatching((item) => projectKeyForItem(item) === projectKey);
348
+ }
349
+
350
+ function restoreBySession(sessionKey) {
351
+ return restoreMatching((item) => sessionKeyForItem(item) === sessionKey);
352
+ }
353
+
354
+ function restoreAll() {
355
+ return restoreMatching(() => true);
356
+ }
357
+
280
358
  function clearQueue() {
281
359
  state = { items: [] };
282
360
  persist();
@@ -298,6 +376,14 @@ function createSpeechQueueStore(options = {}) {
298
376
  markSkipped,
299
377
  replayLatest,
300
378
  replayItem,
379
+ trashItems,
380
+ trashByProject,
381
+ trashBySession,
382
+ trashAll,
383
+ restoreItems,
384
+ restoreByProject,
385
+ restoreBySession,
386
+ restoreAll,
301
387
  clearQueue,
302
388
  getState
303
389
  };
@@ -313,7 +399,8 @@ function isValidPersistedItem(item) {
313
399
  STATUSES.has(item.status) &&
314
400
  item.timestamps &&
315
401
  typeof item.timestamps.createdAt === "string" &&
316
- typeof item.timestamps.updatedAt === "string"
402
+ typeof item.timestamps.updatedAt === "string" &&
403
+ (!("trashedAt" in item) || typeof item.trashedAt === "string")
317
404
  );
318
405
  }
319
406