@micsushi/agent-hotline 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micsushi/agent-hotline",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",
@@ -14,6 +14,7 @@
14
14
  "packages/backend/bin/",
15
15
  "packages/backend/src/",
16
16
  "packages/backend/skills/",
17
+ "packages/backend/web/",
17
18
  "packages/backend/package.json"
18
19
  ],
19
20
  "publishConfig": {
@@ -33,6 +34,10 @@
33
34
  "install-hook": "node scripts/install-hook.js",
34
35
  "install-skill": "node scripts/install-skill.js",
35
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",
39
+ "build:backend-sea": "node scripts/build-backend-sea.mjs",
40
+ "sync-desktop-version": "node scripts/sync-desktop-version.mjs",
36
41
  "test": "npm --prefix packages/backend test && npm --workspace @agent-hotline/desktop run test",
37
42
  "lint": "eslint . && npm run rust:clippy",
38
43
  "lint:fix": "eslint . --fix",
@@ -48,8 +53,10 @@
48
53
  },
49
54
  "devDependencies": {
50
55
  "@eslint/js": "^10.0.1",
56
+ "esbuild": "^0.28.1",
51
57
  "eslint": "^10.5.0",
52
58
  "globals": "^17.6.0",
59
+ "postject": "^1.0.0-alpha.6",
53
60
  "prettier": "^3.8.4"
54
61
  },
55
62
  "repository": {
@@ -15,7 +15,7 @@ const { createAudioCacheStore } = require("./audio-cache-store");
15
15
  const PORT = Number(process.env.AGENT_HOTLINE_PORT || process.env.VOICE_QUESTION_LOOP_PORT || 4777);
16
16
  const HOST = "127.0.0.1";
17
17
  const ROOT = path.resolve(__dirname, "..");
18
- const DATA_DIR = path.join(ROOT, "data");
18
+ const DATA_DIR = process.env.AGENT_HOTLINE_DATA_DIR || path.join(ROOT, "data");
19
19
  const QUESTIONS_FILE = process.env.QUESTION_FILE || path.join(DATA_DIR, "questions.json");
20
20
  const REQUEST_LIMIT_BYTES = 1_000_000;
21
21
  const AUDIO_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
@@ -463,6 +463,68 @@ function getPathname(req) {
463
463
  return new URL(req.url, "http://127.0.0.1").pathname;
464
464
  }
465
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
+
466
528
  function page() {
467
529
  return `<!doctype html>
468
530
  <html lang="en">
@@ -584,22 +646,23 @@ function page() {
584
646
  }
585
647
 
586
648
  function createServer(options = {}) {
649
+ const dataDir = options.dataDir || DATA_DIR;
587
650
  const settingsStore =
588
651
  options.settingsStore ||
589
652
  createSettingsStore({
590
- dataDir: options.dataDir,
653
+ dataDir,
591
654
  settingsPath: options.settingsPath
592
655
  });
593
656
  const queueStore =
594
657
  options.queueStore ||
595
658
  createSpeechQueueStore({
596
- dataDir: options.dataDir,
659
+ dataDir,
597
660
  filePath: options.queuePath
598
661
  });
599
662
  const questionStore =
600
663
  options.questionStore ||
601
664
  createQuestionStore({
602
- dataDir: options.questionDataDir || options.dataDir,
665
+ dataDir: options.questionDataDir || dataDir,
603
666
  questionsFile: options.questionsFile,
604
667
  answersFile: options.answersFile,
605
668
  ensureFiles: options.ensureQuestionFiles
@@ -608,7 +671,7 @@ function createServer(options = {}) {
608
671
  const audioCacheStore =
609
672
  options.audioCacheStore ||
610
673
  createAudioCacheStore({
611
- dataDir: options.dataDir,
674
+ dataDir,
612
675
  cacheDir: options.audioCacheDir,
613
676
  maxBytes: options.audioMaxBytes,
614
677
  getMaxBytes: options.audioMaxBytes
@@ -617,8 +680,7 @@ function createServer(options = {}) {
617
680
  });
618
681
 
619
682
  const spoolStore =
620
- options.spoolStore ||
621
- createSpoolStore({ dataDir: options.dataDir, filePath: options.spoolPath });
683
+ options.spoolStore || createSpoolStore({ dataDir, filePath: options.spoolPath });
622
684
  try {
623
685
  spoolStore.drain((item) => queueStore.enqueue(item));
624
686
  } catch {}
@@ -634,11 +696,25 @@ function createServer(options = {}) {
634
696
  const pathname = getPathname(req);
635
697
 
636
698
  if (req.method === "GET" && pathname === "/") {
699
+ const index = resolveStaticFile("/index.html");
700
+ if (index) {
701
+ serveStaticFile(res, index);
702
+ return;
703
+ }
637
704
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
638
705
  res.end(page());
639
706
  return;
640
707
  }
641
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
+
642
718
  if (req.method === "GET" && (pathname === "/health" || pathname === "/api/health")) {
643
719
  sendJson(res, 200, { ok: true, service: "agent-hotline", host: HOST });
644
720
  return;
@@ -901,6 +977,16 @@ function createServer(options = {}) {
901
977
  return;
902
978
  }
903
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
+
904
990
  throw createHttpError(404, "not_found", "Not found");
905
991
  } catch (error) {
906
992
  if (!error.status && /Queue item not found/.test(error.message)) {
@@ -8,6 +8,7 @@ const READ_BEHAVIORS = new Set(["manual", "auto"]);
8
8
  const TTS_ENGINES = new Set(["webview", "kokoro", "kokoro-ts"]);
9
9
  const NOTIFICATION_OPENS = new Set(["full", "mini"]);
10
10
  const AUDIO_CACHE_LIMIT_MAX_MB = 100000;
11
+ const DEFAULT_RATE = 0.9;
11
12
 
12
13
  const DEFAULT_SETTINGS = Object.freeze({
13
14
  readBehavior: "manual",
@@ -16,7 +17,7 @@ const DEFAULT_SETTINGS = Object.freeze({
16
17
  voice: "",
17
18
  audioOutputDeviceId: "",
18
19
  kokoroVoice: "af_heart",
19
- rate: 0.92,
20
+ rate: DEFAULT_RATE,
20
21
  volume: 1,
21
22
  skipRules: Object.freeze({
22
23
  codeBlocks: true,
@@ -32,7 +33,9 @@ const DEFAULT_SETTINGS = Object.freeze({
32
33
  notifyOnNewReply: false,
33
34
  notificationOpens: "full",
34
35
  highlightSpokenText: false,
35
- audioCacheLimitMb: 1024
36
+ audioCacheLimitMb: 1024,
37
+ startupSplash: true,
38
+ startupJingle: true
36
39
  });
37
40
 
38
41
  function getDefaultDataDir(env = process.env, platform = process.platform) {
@@ -79,6 +82,11 @@ function numberInRangeOrDefault(value, fallback, min, max) {
79
82
  return number >= min && number <= max ? number : fallback;
80
83
  }
81
84
 
85
+ function normalizeRate(value, fallback) {
86
+ const rate = numberInRangeOrDefault(value, fallback, 0.1, 10);
87
+ return rate === 0.92 ? DEFAULT_RATE : rate;
88
+ }
89
+
82
90
  function normalizeSettings(input) {
83
91
  const source = isPlainObject(input) ? input : {};
84
92
  const defaults = DEFAULT_SETTINGS;
@@ -93,7 +101,7 @@ function normalizeSettings(input) {
93
101
  voice: stringOrDefault(source.voice, defaults.voice),
94
102
  audioOutputDeviceId: stringOrDefault(source.audioOutputDeviceId, defaults.audioOutputDeviceId),
95
103
  kokoroVoice: stringOrDefault(source.kokoroVoice, defaults.kokoroVoice),
96
- rate: numberInRangeOrDefault(source.rate, defaults.rate, 0.1, 10),
104
+ rate: normalizeRate(source.rate, defaults.rate),
97
105
  volume: numberInRangeOrDefault(source.volume, defaults.volume, 0, 1),
98
106
  skipRules: {
99
107
  codeBlocks: booleanOrDefault(sourceSkipRules.codeBlocks, defaults.skipRules.codeBlocks),
@@ -119,7 +127,9 @@ function normalizeSettings(input) {
119
127
  defaults.audioCacheLimitMb,
120
128
  10,
121
129
  AUDIO_CACHE_LIMIT_MAX_MB
122
- )
130
+ ),
131
+ startupSplash: booleanOrDefault(source.startupSplash, defaults.startupSplash),
132
+ startupJingle: booleanOrDefault(source.startupJingle, defaults.startupJingle)
123
133
  };
124
134
  }
125
135