Repository navigation
feat(single-app): collapse daemon + overlay into one Electron app (ADR-0003, Phase 1) - #68
Conversation
Phase 0 proof of the single-app topology. A new src/app/main.ts hosts DaemonService and the overlay window in one Electron process; the daemon still emits state over its unix socket and the window's IPCClient connects in-process (loopback) — Phase 1 replaces that hop with webContents.send. Three env-gated seams make this bootable alongside the running daemon: - HYPRVOX_EMBEDDED_OVERLAY: overlay-process.start() no-ops (no child Electron) - HYPRVOX_SOCKET_PATH: alternate IPC socket (ipc.ts + overlay ipc-client) - HYPRVOX_PID_FILE: alternate daemon pidfile Verified: bundles via esbuild (ESM, electron + node-global-key-listener external), boots under Electron as a single PID, daemon + overlay + IPC all live, no second Electron spawned, running daemon untouched.
…utdown Audit of the full dependency tree found four packages that cannot be inlined by a bundler (vendored binaries, dynamic require, package-relative asset paths): node-record-lpcm16, node-global-key-listener, clipboardy, plus a latent clipboardy xsel-fallback path break. Resolution: bundle only our own code (esbuild --packages=external); all deps resolve from node_modules at runtime, deleting the whole bug class. - recorder: default-import node-record-lpcm16 (CJS named export invisible to Node's ESM lexer; crashed under Electron, tolerated by Bun) - app main: boot failure exits instead of leaving a half-alive window; SIGTERM/SIGINT -> app.quit -> service.stop so the socket file is unlinked and the next boot cannot EADDRINUSE - tsconfig: exclude src/app (Electron types live in the overlay project; the app gets its own tsconfig in Phase 1) Verified end to end in one Electron process: SIGUSR1 trigger -> arecord -> Groq+Deepgram streaming -> merge/validation (silence hallucination correctly filtered) -> idle. 254 tests pass under bun; typecheck clean.
Replace Bun-only import.meta.dir in project-paths with a fileURLToPath-anchored walk up to the hyprvox package.json, so the resolver works under Electron/Node as well as Bun. Add socket-path util exporting HYPRVOX_SOCKET_PATH-aware daemon.sock location for the app command socket and CLI verbs to share.
…rocess Delete the supervision stack (supervisor, overlay-process manager, daemon IPC socket server, overlay ipc-client and its Electron main) and the crash-recovery integration test that exercised the supervisor. DaemonService now extends EventEmitter and emits state/audioLevel instead of pushing over a socket. src/app/main.ts hosts the daemon in-process, creates the overlay BrowserWindow, and forwards state to the renderer via webContents.send. command-server.ts binds the unix command socket for payload-carrying CLI verbs (soniox-toggle) and doubles as the single-instance guard. Overlay renderer/preload type imports repoint to shared/ipc-types.
Add app-launcher.ts (locates dist/app + overlay's Electron binary, strips ELECTRON_OZONE_PLATFORM_HINT so the window comes up as an XWayland client, spawns detached/foreground). start/stop/restart now target the app process; toggle lazily respawns a dead app before delivering the trigger (the crash-recovery path). install prints Hyprland exec-once guidance instead of writing a systemd unit. Remove the standalone overlay command; reword health and setup for the single-app topology.
scripts/build-app.ts esbuild-bundles src/app/main.ts to dist/app/main.js (packages external, electron external, tsconfigRaw override to defeat path mapping, ESM-compat banner) and writes dist/app/package.json with name hyprvox-overlay so the window's WM_CLASS is hyprvox-overlay. src/app/tsconfig.json extends root and points electron types at overlay's node_modules. package.json gains typecheck/typecheck:app/build:app scripts and the esbuild devDependency; start:overlay removed.
Add ADR-0003 (the daemon+overlay collapse) with Phase 1 implementation notes on the XWayland dependency, WM_CLASS source, command socket, and the drop of the systemd install path. Update ARCHITECTURE, CLI_COMMANDS, and AGENTS to describe the single Electron app, exec-once launch, lazy-spawn crash recovery, and the command socket.
📝 WalkthroughWalkthroughThe daemon, overlay, and supervision layers are consolidated into one Electron app. New app bundling and command-socket infrastructure support in-process daemon events, renderer updates, single-instance control, revised CLI lifecycle commands, and Hyprland autostart instructions. ChangesSingle Electron application
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant CLI
participant ElectronMain
participant CommandServer
participant DaemonService
participant Renderer
CLI->>ElectronMain: start or launch app
ElectronMain->>CommandServer: start single-instance command socket
ElectronMain->>DaemonService: start daemon in-process
CLI->>CommandServer: send toggle or soniox-toggle action
CommandServer->>DaemonService: invoke action callback
DaemonService->>Renderer: send state and audioLevel events
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 88c632bfe1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!existsSync(join(appDir, "main.js"))) { | ||
| throw new Error( | ||
| `App bundle not found at ${appDir}. Build it with: bun run build:app`, | ||
| ); |
There was a problem hiding this comment.
Ship the app bundle with packaged installs
For bun add -g hyprvox installs, package.json still publishes only index.ts, src, README.md, and package.json, so dist/app/main.js and overlay/node_modules/.bin/electron are not present. Since every start/lazy toggle path now calls assertAppLaunchable(), packaged users hit this error and cannot start the app unless they have a source checkout. Please include/build these artifacts during packaging or keep a runnable fallback.
Useful? React with 👍 / 👎.
| program | ||
| .command("start") | ||
| .description("Start the hyprvox app (daemon + overlay in one process)") | ||
| .option("--foreground", "Stay attached to the terminal (for debugging)") |
There was a problem hiding this comment.
Keep accepting legacy start flags
Previous hyprvox install wrote a systemd unit with ExecStart=... start --no-supervisor, but the new start command only accepts --foreground. Commander treats unknown options as errors unless allowUnknownOption() is used (docs), so any existing enabled unit fails after an upgrade/reboot before the user removes it. Please keep --no-supervisor as a hidden no-op for compatibility.
Useful? React with 👍 / 👎.
| "get-connection-status", | ||
| (): ConnectionStatus => (service ? "connected" : "disconnected"), | ||
| ); | ||
| mainWindow = createOverlayWindow(); |
There was a problem hiding this comment.
Honor disabled overlay settings
For configs with overlay.enabled=false, the old OverlayProcessManager.start() returned without opening Electron and the service used desktop notifications instead. This boot path always creates the renderer and forwards daemon state, so a disabled-overlay user still gets the window moved on-screen during recording despite health reporting it disabled. Please gate window creation/state forwarding on the overlay config or remove/migrate the setting.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/main.ts`:
- Around line 24-27: Update src/app/main.ts to import and use the shared pino
logger for every listed console.log and console.error call, including the
display-environment failure and the ranges around lines 62-63, 121-148, 188-193,
and 215-238. Preserve each message, severity, and control flow while routing
output through the logger used by command-server.ts; remove direct console
logging from this file.
- Around line 161-174: Update recreateOverlayWindow to prevent indefinite
recreation attempts when createOverlayWindow repeatedly produces a broken
overlay: track consecutive retry attempts, apply increasing backoff delays, and
stop scheduling retries after a defined maximum. Reset the retry state only
after a successfully usable overlay is established, while preserving the
existing destroy-and-recreate behavior.
- Around line 242-254: Update the will-quit shutdown flow around the stopping
guard to add a finite timeout fallback that calls app.exit(0) even if
service?.stop() or commandServer?.stop() never resolves. Preserve the existing
graceful stop sequence, error logging, and stopping guard, while ensuring the
timeout is cleared when shutdown completes normally to avoid duplicate exit
handling.
- Around line 62-63: Update the uncaughtException and unhandledRejection
handlers in the process-level error handling to terminate the main process with
exit status 1 after logging, using app.exit(1) or equivalent; preserve the
existing error messages and ensure both handlers cannot leave a broken instance
running.
In `@src/cli/app-launcher.ts`:
- Around line 58-64: Update spawnAppDetached() and spawnAppForeground() to
return promises that listen for the child process "error" and "spawn" events,
rejecting on launch failure and resolving only after successful spawning. Ensure
the success message is printed only after awaiting these promises, while
preserving the existing detached/foreground process behavior.
- Around line 53-60: Update spawnAppDetached to direct the detached child’s
stdout and stderr to the existing daily-rotated hyprvox-YYYY-MM-DD.log
destination instead of opening and using app.log. Reuse the established
rotated-log path construction and preserve the existing detached spawn behavior.
In `@src/cli/health.ts`:
- Around line 290-297: Update the socket existence check in the health-check
flow to use the shared SOCKET_PATH contract instead of rebuilding the path with
join(configDir, "daemon.sock"). Import and check SOCKET_PATH directly so
HYPRVOX_SOCKET_PATH overrides are respected.
- Around line 269-279: Update the legacy systemd check in the health command to
distinguish an inactive existing unit from an absent unit: check unit existence
separately from is-active, and reserve the “No legacy systemd service” message
for units that are truly missing. Also update the overlay socket health check to
use the configured SOCKET_PATH value rather than rebuilding daemon.sock from
configDir, preserving support for HYPRVOX_SOCKET_PATH.
In `@src/cli/index.ts`:
- Around line 51-103: Update the stop, restart, and toggle flows to verify the
PID belongs to the running hyprvox instance before sending SIGTERM or SIGUSR1;
readAlivePid only confirms that some process owns the PID. Reuse the existing
command socket or app-owned identity token for this validation, and abort safely
when identity cannot be confirmed.
In `@src/cli/setup.ts`:
- Line 850: Update the remaining messages near the Hyprland autostart prompt and
the corresponding later location to describe skipping autostart instructions
under the --skip-service flow, replacing the outdated “Skipping service install”
wording while preserving the surrounding control flow.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: abb81a9e-4397-4865-b7b3-b8a026fff950
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (29)
AGENTS.mddocs/ARCHITECTURE.mddocs/CLI_COMMANDS.mddocs/adr/0003-collapse-daemon-and-overlay-into-one-electron-app.mdoverlay/package.jsonoverlay/src/global.d.tsoverlay/src/ipc-client.tsoverlay/src/main.tsoverlay/src/preload.tsoverlay/src/renderer/useDaemonState.tspackage.jsonscripts/build-app.tssrc/app/command-server.tssrc/app/main.tssrc/app/tsconfig.jsonsrc/audio/recorder.tssrc/cli/app-launcher.tssrc/cli/health.tssrc/cli/index.tssrc/cli/overlay.tssrc/cli/setup.tssrc/daemon/ipc.tssrc/daemon/overlay-process.tssrc/daemon/service.tssrc/daemon/supervisor.tssrc/utils/project-paths.tssrc/utils/socket-path.tstests/integration/crash_recovery.test.tstsconfig.json
💤 Files with no reviewable changes (7)
- src/daemon/ipc.ts
- src/daemon/overlay-process.ts
- overlay/src/main.ts
- overlay/src/ipc-client.ts
- src/cli/overlay.ts
- tests/integration/crash_recovery.test.ts
- src/daemon/supervisor.ts
| if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { | ||
| console.error("[App] No display environment (DISPLAY or WAYLAND_DISPLAY)"); | ||
| process.exit(1); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use the shared pino logger instead of console.*.
This file never imports/uses the pino logger (as command-server.ts does) and logs exclusively via console.log/console.error throughout. This loses structured JSON output, level filtering, and the daily rotation the rest of the codebase relies on.
As per coding guidelines, "Use pino for logging, with daily log rotation" for src/**/*.ts.
Also applies to: 62-63, 121-148, 188-193, 215-238
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/main.ts` around lines 24 - 27, Update src/app/main.ts to import and
use the shared pino logger for every listed console.log and console.error call,
including the display-environment failure and the ranges around lines 62-63,
121-148, 188-193, and 215-238. Preserve each message, severity, and control flow
while routing output through the logger used by command-server.ts; remove direct
console logging from this file.
Source: Coding guidelines
| process.on("uncaughtException", (err) => console.error("[App] uncaught:", err)); | ||
| process.on("unhandledRejection", (r) => console.error("[App] unhandled:", r)); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== files ==\n'
git ls-files | rg '(^|/)(main\.ts|.*toggle.*|.*cli.*|.*daemon.*|.*adr.*|.*doc.*|README\.md)$' || true
printf '\n== search terms ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
'uncaughtException|unhandledRejection|hyprvox toggle|pidfile|pid file|socket|liveness|respawn|crash recovery|half-alive|half broken|boot\(\)|app.exit\(1\)' .Repository: Snehit70/hyprvox
Length of output: 14879
🏁 Script executed:
...Repository: Snehit70/hyprvox
Length of output: 195
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== main.ts slice ==\n'
if [ -f src/app/main.ts ]; then
nl -ba src/app/main.ts | sed -n '1,320p'
fi
printf '\n== find toggle / crash recovery references ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
'toggle|crash recovery|uncaughtException|unhandledRejection|pidfile|socket|respawn|liveness|boot\(' src README.md .github docs 2>/dev/null || trueRepository: Snehit70/hyprvox
Length of output: 215
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Focus on likely CLI / daemon files and the specific app file.
git ls-files | rg '^(src/.*|README\.md|docs/.*|.*ADR.*)$' | sed -n '1,200p'
printf '\n== direct references ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
'toggle|pidfile|socket|respawn|liveness|daemon|boot\(' src README.md docs . 2>/dev/null || true
printf '\n== main.ts excerpt ==\n'
nl -ba src/app/main.ts | sed -n '50,80p;225,245p'Repository: Snehit70/hyprvox
Length of output: 50374
Exit the main process on uncaught failures. Logging here is not enough: this app relies on the process dying so the next hyprvox toggle can lazily respawn it, and a half-broken instance can keep the pidfile/socket while serving nothing. Re-throw or call app.exit(1) after logging.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/main.ts` around lines 62 - 63, Update the uncaughtException and
unhandledRejection handlers in the process-level error handling to terminate the
main process with exit status 1 after logging, using app.exit(1) or equivalent;
preserve the existing error messages and ensure both handlers cannot leave a
broken instance running.
| function recreateOverlayWindow(): void { | ||
| if (mainWindow) { | ||
| mainWindow.destroy(); | ||
| mainWindow = null; | ||
| } | ||
| setTimeout(() => { | ||
| if (!mainWindow) { | ||
| mainWindow = createOverlayWindow(); | ||
| mainWindow.on("closed", () => { | ||
| mainWindow = null; | ||
| }); | ||
| } | ||
| }, 2000); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Unbounded overlay-recreate loop, no retry cap or backoff.
If the overlay assets are broken (bad build, missing preload) every unresponsive/render-process-gone event retriggers recreateOverlayWindow(), which recreates the same broken window every 2s indefinitely.
🛠️ Suggested fix: cap retries with backoff
+let recreateAttempts = 0;
+const MAX_RECREATE_ATTEMPTS = 5;
+
function recreateOverlayWindow(): void {
+ recreateAttempts += 1;
+ if (recreateAttempts > MAX_RECREATE_ATTEMPTS) {
+ console.error("[App] Overlay recreate limit exceeded, giving up");
+ return;
+ }
if (mainWindow) {
mainWindow.destroy();
mainWindow = null;
}
setTimeout(() => {
if (!mainWindow) {
mainWindow = createOverlayWindow();
+ recreateAttempts = 0;
mainWindow.on("closed", () => {
mainWindow = null;
});
}
- }, 2000);
+ }, 2000 * recreateAttempts);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function recreateOverlayWindow(): void { | |
| if (mainWindow) { | |
| mainWindow.destroy(); | |
| mainWindow = null; | |
| } | |
| setTimeout(() => { | |
| if (!mainWindow) { | |
| mainWindow = createOverlayWindow(); | |
| mainWindow.on("closed", () => { | |
| mainWindow = null; | |
| }); | |
| } | |
| }, 2000); | |
| } | |
| let recreateAttempts = 0; | |
| const MAX_RECREATE_ATTEMPTS = 5; | |
| function recreateOverlayWindow(): void { | |
| recreateAttempts += 1; | |
| if (recreateAttempts > MAX_RECREATE_ATTEMPTS) { | |
| console.error("[App] Overlay recreate limit exceeded, giving up"); | |
| return; | |
| } | |
| if (mainWindow) { | |
| mainWindow.destroy(); | |
| mainWindow = null; | |
| } | |
| setTimeout(() => { | |
| if (!mainWindow) { | |
| mainWindow = createOverlayWindow(); | |
| recreateAttempts = 0; | |
| mainWindow.on("closed", () => { | |
| mainWindow = null; | |
| }); | |
| } | |
| }, 2000 * recreateAttempts); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/main.ts` around lines 161 - 174, Update recreateOverlayWindow to
prevent indefinite recreation attempts when createOverlayWindow repeatedly
produces a broken overlay: track consecutive retry attempts, apply increasing
backoff delays, and stop scheduling retries after a defined maximum. Reset the
retry state only after a successfully usable overlay is established, while
preserving the existing destroy-and-recreate behavior.
| let stopping = false; | ||
| app.on("will-quit", (event: { preventDefault: () => void }) => { | ||
| if (stopping || (!service && !commandServer)) { | ||
| return; | ||
| } | ||
| stopping = true; | ||
| event.preventDefault(); | ||
| Promise.resolve() | ||
| .then(() => service?.stop()) | ||
| .then(() => commandServer?.stop()) | ||
| .catch((err) => console.error("[App] Shutdown error:", err)) | ||
| .finally(() => app.exit(0)); | ||
| }); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
No timeout on graceful shutdown; a hung service.stop()/commandServer.stop() blocks termination.
event.preventDefault() blocks quit until this chain resolves. If either stop() call hangs, the process never calls app.exit(), and the stopping guard makes a repeated SIGTERM a no-op — the process becomes unkillable short of SIGKILL.
🛠️ Suggested fix: add a shutdown timeout fallback
stopping = true;
event.preventDefault();
- Promise.resolve()
+ const shutdownTimeout = setTimeout(() => {
+ console.error("[App] Shutdown timed out, forcing exit");
+ app.exit(1);
+ }, 5000);
+ Promise.resolve()
.then(() => service?.stop())
.then(() => commandServer?.stop())
.catch((err) => console.error("[App] Shutdown error:", err))
- .finally(() => app.exit(0));
+ .finally(() => {
+ clearTimeout(shutdownTimeout);
+ app.exit(0);
+ });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let stopping = false; | |
| app.on("will-quit", (event: { preventDefault: () => void }) => { | |
| if (stopping || (!service && !commandServer)) { | |
| return; | |
| } | |
| stopping = true; | |
| event.preventDefault(); | |
| Promise.resolve() | |
| .then(() => service?.stop()) | |
| .then(() => commandServer?.stop()) | |
| .catch((err) => console.error("[App] Shutdown error:", err)) | |
| .finally(() => app.exit(0)); | |
| }); | |
| let stopping = false; | |
| app.on("will-quit", (event: { preventDefault: () => void }) => { | |
| if (stopping || (!service && !commandServer)) { | |
| return; | |
| } | |
| stopping = true; | |
| event.preventDefault(); | |
| const shutdownTimeout = setTimeout(() => { | |
| console.error("[App] Shutdown timed out, forcing exit"); | |
| app.exit(1); | |
| }, 5000); | |
| Promise.resolve() | |
| .then(() => service?.stop()) | |
| .then(() => commandServer?.stop()) | |
| .catch((err) => console.error("[App] Shutdown error:", err)) | |
| .finally(() => { | |
| clearTimeout(shutdownTimeout); | |
| app.exit(0); | |
| }); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/main.ts` around lines 242 - 254, Update the will-quit shutdown flow
around the stopping guard to add a finite timeout fallback that calls
app.exit(0) even if service?.stop() or commandServer?.stop() never resolves.
Preserve the existing graceful stop sequence, error logging, and stopping guard,
while ensuring the timeout is cleared when shutdown completes normally to avoid
duplicate exit handling.
| export function spawnAppDetached(): number | undefined { | ||
| assertAppLaunchable(); | ||
| mkdirSync(logsDir, { recursive: true, mode: 0o700 }); | ||
| const logFd = openSync(join(logsDir, "app.log"), "a"); | ||
|
|
||
| const child = spawn(electronBinary, [appDir], { | ||
| detached: true, | ||
| stdio: ["ignore", logFd, logFd], |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C3 'app\.log|pino|rotat' src package.json overlay/package.jsonRepository: Snehit70/hyprvox
Length of output: 5463
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## outline: src/utils/logger.ts\n'
ast-grep outline src/utils/logger.ts --view expanded || true
printf '\n## outline: src/cli/app-launcher.ts\n'
ast-grep outline src/cli/app-launcher.ts --view expanded || true
printf '\n## outline: src/cli/logs.ts\n'
ast-grep outline src/cli/logs.ts --view expanded || true
printf '\n## relevant slices\n'
sed -n '1,220p' src/utils/logger.ts
printf '\n--- app-launcher ---\n'
sed -n '1,180p' src/cli/app-launcher.ts
printf '\n--- logs ---\n'
sed -n '1,220p' src/cli/logs.tsRepository: Snehit70/hyprvox
Length of output: 9889
Redirect detached app output away from app.log (src/cli/app-launcher.ts:56).
The app already writes to the daily-rotated hyprvox-YYYY-MM-DD.log; this extra app.log sink duplicates stdout/stderr and grows without bound. Point detached output at the rotated log path, or rotate app.log too.
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/app-launcher.ts` around lines 53 - 60, Update spawnAppDetached to
direct the detached child’s stdout and stderr to the existing daily-rotated
hyprvox-YYYY-MM-DD.log destination instead of opening and using app.log. Reuse
the established rotated-log path construction and preserve the existing detached
spawn behavior.
Source: Coding guidelines
| const child = spawn(electronBinary, [appDir], { | ||
| detached: true, | ||
| stdio: ["ignore", logFd, logFd], | ||
| env: buildAppEnv(), | ||
| }); | ||
| child.unref(); | ||
| closeSync(logFd); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
ast-grep outline src/cli/app-launcher.ts \
--match 'spawnAppDetached|spawnAppForeground' --view expanded
rg -n -C3 'child\.(on|once)\("error"|spawnApp(Detached|Foreground)\(' src/cliRepository: Snehit70/hyprvox
Length of output: 2171
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,140p' src/cli/app-launcher.ts
printf '\n---\n'
sed -n '90,130p' src/cli/index.ts
printf '\n---\n'
sed -n '270,305p' src/cli/index.tsRepository: Snehit70/hyprvox
Length of output: 4671
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
text = Path('src/cli/app-launcher.ts').read_text()
for name in ['spawnAppDetached', 'spawnAppForeground']:
idx = text.find(f'export function {name}')
print(f'\n{name}:')
print(text[idx:text.find('\n}', idx)+2])
PYRepository: Snehit70/hyprvox
Length of output: 801
Wait for child to emit "spawn" before printing success. spawn() reports launch failures asynchronously via "error", so the current try/catch can still print “App launched” even when Electron never starts. Make spawnAppDetached() and spawnAppForeground() return a promise that rejects on "error" and resolves only after "spawn".
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/app-launcher.ts` around lines 58 - 64, Update spawnAppDetached() and
spawnAppForeground() to return promises that listen for the child process
"error" and "spawn" events, rejecting on launch failure and resolving only after
successful spawning. Ensure the success message is printed only after awaiting
these promises, while preserving the existing detached/foreground process
behavior.
| `${colors.yellow("⚠️")} Legacy systemd service is active — the app now supervises itself; remove it with ${colors.cyan("hyprvox uninstall")}`, | ||
| ); | ||
| } else { | ||
| console.log( | ||
| `${colors.yellow("⚠️")} systemd service: ${colors.yellow(isServiceActive)}`, | ||
| `${colors.blue("ℹ️")} Legacy systemd service present but ${isServiceActive}`, | ||
| ); | ||
| } | ||
| } catch (_e) { | ||
| console.log( | ||
| `${colors.blue("ℹ️")} systemd service not active or not installed`, | ||
| `${colors.blue("ℹ️")} No legacy systemd service (expected; app is launched via exec-once)`, | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set +e
output=$(systemctl --user is-active hyprvox 2>&1)
status=$?
printf 'exit=%s output=%s\n' "$status" "$output"Repository: Snehit70/hyprvox
Length of output: 213
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the relevant section of the cited file
wc -l src/cli/health.ts
sed -n '230,320p' src/cli/health.ts
# Find socket-path and systemd-related symbols/usages
rg -n "SOCKET_PATH|systemctl|is-active|legacy systemd|hyprvox uninstall|exec-once" src/cli/health.ts src -g '!**/dist/**' -g '!**/build/**'Repository: Snehit70/hyprvox
Length of output: 6287
🏁 Script executed:
#!/bin/bash
set -euo pipefail
wc -l src/cli/health.ts
sed -n '230,320p' src/cli/health.ts
rg -n "SOCKET_PATH|systemctl|is-active|legacy systemd|hyprvox uninstall|exec-once" src/cli/health.ts src -g '!**/dist/**' -g '!**/build/**'Repository: Snehit70/hyprvox
Length of output: 6287
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect imports and the overlay/socket check in the health command
sed -n '1,80p' src/cli/health.ts
sed -n '280,315p' src/cli/health.ts
# Inspect the shared socket path helper
sed -n '1,40p' src/utils/socket-path.tsRepository: Snehit70/hyprvox
Length of output: 4238
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find where configDir comes from in the health command
rg -n "configDir" src/cli/health.ts src/cli src -g '!**/dist/**' -g '!**/build/**'
# Read the surrounding health.ts slice if configDir is local
sed -n '80,220p' src/cli/health.tsRepository: Snehit70/hyprvox
Length of output: 7579
Handle inactive legacy units and custom socket paths
systemctl --user is-active hyprvoxreportsinactivethrough a nonzero exit, so thecatchpath treats an installed-but-stopped unit as missing. Check unit existence separately and only emit “no legacy systemd service” when it’s actually absent.- The overlay health check rebuilds
join(configDir, "daemon.sock")instead of usingSOCKET_PATH, soHYPRVOX_SOCKET_PATHinstances can be reported as down even when their socket exists.
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/health.ts` around lines 269 - 279, Update the legacy systemd check in
the health command to distinguish an inactive existing unit from an absent unit:
check unit existence separately from is-active, and reserve the “No legacy
systemd service” message for units that are truly missing. Also update the
overlay socket health check to use the configured SOCKET_PATH value rather than
rebuilding daemon.sock from configDir, preserving support for
HYPRVOX_SOCKET_PATH.
| const socketPath = join(configDir, "daemon.sock"); | ||
| if (existsSync(socketPath)) { | ||
| console.log( | ||
| `${colors.green("✅")} IPC socket available for overlay connection`, | ||
| `${colors.green("✅")} App command socket present (overlay window hosted in-process)`, | ||
| ); | ||
| } else { | ||
| console.log( | ||
| `${colors.yellow("⚠️")} IPC socket not found (daemon not running?)`, | ||
| `${colors.yellow("⚠️")} Command socket not found (app not running?)`, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use the shared SOCKET_PATH contract.
Reconstructing the default path ignores HYPRVOX_SOCKET_PATH, causing health checks for test or remapped instances to report the app as stopped. Import and check SOCKET_PATH directly.
Proposed fix
- const socketPath = join(configDir, "daemon.sock");
- if (existsSync(socketPath)) {
+ if (existsSync(SOCKET_PATH)) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const socketPath = join(configDir, "daemon.sock"); | |
| if (existsSync(socketPath)) { | |
| console.log( | |
| `${colors.green("✅")} IPC socket available for overlay connection`, | |
| `${colors.green("✅")} App command socket present (overlay window hosted in-process)`, | |
| ); | |
| } else { | |
| console.log( | |
| `${colors.yellow("⚠️")} IPC socket not found (daemon not running?)`, | |
| `${colors.yellow("⚠️")} Command socket not found (app not running?)`, | |
| if (existsSync(SOCKET_PATH)) { | |
| console.log( | |
| `${colors.green("✅")} App command socket present (overlay window hosted in-process)`, | |
| ); | |
| } else { | |
| console.log( | |
| `${colors.yellow("⚠️")} Command socket not found (app not running?)`, |
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/health.ts` around lines 290 - 297, Update the socket existence check
in the health-check flow to use the shared SOCKET_PATH contract instead of
rebuilding the path with join(configDir, "daemon.sock"). Import and check
SOCKET_PATH directly so HYPRVOX_SOCKET_PATH overrides are respected.
| /** Pid from the pidfile if that process is alive; null otherwise. */ | ||
| function readAlivePid(): number | null { | ||
| if (!existsSync(pidFile)) { | ||
| return null; | ||
| } | ||
| try { | ||
| const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); | ||
| if (Number.isNaN(pid)) { | ||
| return null; | ||
| } | ||
| process.kill(pid, 0); | ||
| return pid; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| if (options.supervisor && !process.env.HYPRVOX_DAEMON_WORKER) { | ||
| console.log(`${colors.cyan("Starting daemon with supervisor...")}`); | ||
| const supervisor = new DaemonSupervisor(join(projectRoot, "index.ts")); | ||
| supervisor.start(); | ||
| } else { | ||
| console.log(`${colors.cyan("Starting daemon worker...")}`); | ||
| let service: DaemonService; | ||
| try { | ||
| service = new DaemonService(); | ||
| service.start().catch((err) => { | ||
| console.error(colors.red("\nFailed to start daemon:"), err.message); | ||
| process.exit(1); | ||
| }); | ||
| } catch (err: any) { | ||
| console.error( | ||
| colors.red("\nFailed to initialize daemon:"), | ||
| err.message, | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| function cleanStalePidFile(): void { | ||
| if (existsSync(pidFile) && readAlivePid() === null) { | ||
| console.log( | ||
| colors.yellow("Cleaning up stale PID file from previous session..."), | ||
| ); | ||
| try { | ||
| unlinkSync(pidFile); | ||
| } catch { | ||
| // PID file may have already been removed | ||
| } | ||
| } | ||
| } | ||
|
|
||
| process.on("SIGINT", () => { | ||
| service.stop(); | ||
| process.exit(0); | ||
| }); | ||
| /** Wait for the app's DaemonService to write its pidfile after a spawn. */ | ||
| async function waitForAlivePid(timeoutMs = 10000): Promise<number | null> { | ||
| const start = Date.now(); | ||
| while (Date.now() - start < timeoutMs) { | ||
| const pid = readAlivePid(); | ||
| if (pid !== null) { | ||
| return pid; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 200)); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| process.on("SIGTERM", () => { | ||
| service.stop(); | ||
| process.exit(0); | ||
| }); | ||
| function startApp(foreground: boolean): void { | ||
| const runningPid = readAlivePid(); | ||
| if (runningPid !== null) { | ||
| console.error( | ||
| colors.red(`Error: hyprvox is already running (PID: ${runningPid})`), | ||
| ); | ||
| console.log(`To stop it, run: ${colors.cyan("hyprvox stop")}`); | ||
| process.exit(1); | ||
| } | ||
| cleanStalePidFile(); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C4 '\breadAlivePid\s*\(|\bprocess\.kill\s*\(' src/cli/index.ts
ast-grep outline src/app/main.ts --match boot --view expandedRepository: Snehit70/hyprvox
Length of output: 3354
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== src/cli/index.ts outline ==\n'
ast-grep outline src/cli/index.ts --view expanded
printf '\n== identity/socket/token references ==\n'
rg -n -C 3 'socket|token|nonce|uuid|pidfile|SIGUSR1|SIGTERM|DaemonService|webContents.send|ipcMain|net\.createServer|createServer|connect\(' src -g '!**/dist/**' -g '!**/build/**'
printf '\n== likely daemon service files ==\n'
fd -a 'DaemonService.*|daemon.*|main.*|ipc.*|socket.*' srcRepository: Snehit70/hyprvox
Length of output: 35993
🏁 Script executed:
#!/bin/bash
set -euo pipefail
git ls-files 'src/app/**' 'src/cli/**' 'src/**' | sed -n '1,200p'Repository: Snehit70/hyprvox
Length of output: 2162
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== pidfile writes in daemon service and launcher ==\n'
rg -n -C 3 'pidFile|daemon\.pid|writeFileSync\(.*pid|unlinkSync\(.*pid|process\.pid' src/daemon src/app src/cli
printf '\n== relevant sections of src/daemon/service.ts ==\n'
sed -n '1,240p' src/daemon/service.ts
printf '\n== relevant sections of src/cli/index.ts around start/restart/toggle ==\n'
sed -n '90,330p' src/cli/index.tsRepository: Snehit70/hyprvox
Length of output: 24014
Validate the live instance before sending SIGTERM/SIGUSR1. src/cli/index.ts:52-60, 139-177, 280-308 A pidfile check only proves some process owns that PID; if it’s stale or reused, stop/restart/toggle can target the wrong process. Use the existing command socket, or another app-owned token, to confirm identity before acting.
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/index.ts` around lines 51 - 103, Update the stop, restart, and toggle
flows to verify the PID belongs to the running hyprvox instance before sending
SIGTERM or SIGUSR1; readAlivePid only confirms that some process owns the PID.
Reuse the existing command socket or app-owned identity token for this
validation, and abort safely when identity cannot be confirmed.
| } | ||
|
|
||
| if (!askYesNoQuit("Install and start the systemd user service now?")) { | ||
| if (!askYesNoQuit("Show Hyprland autostart instructions now?")) { |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the remaining “service install” messages.
These changes redefine the flow as showing autostart instructions, but Lines 813, 824, and 833 still say “Skipping service install.” Update them to match the new --skip-service semantics.
Also applies to: 1004-1004
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/cli/setup.ts` at line 850, Update the remaining messages near the
Hyprland autostart prompt and the corresponding later location to describe
skipping autostart instructions under the --skip-service flow, replacing the
outdated “Skipping service install” wording while preserving the surrounding
control flow.
Summary
Collapses the two-process topology (Bun daemon + Electron overlay talking over a unix socket, with four supervision layers) into one resident Electron app: the main process hosts the daemon and the overlay
BrowserWindow, state flows main → renderer viawebContents.send, and Electron is the single supervisor. Implements ADR-0003, Phase 1.Builds on the earlier spike commits (
fae7737,2da7023) already on this branch.What changed
supervisor.ts,overlay-process.ts, daemon IPC socket server, overlayipc-client.ts+ its Electronmain.ts) and the crash-recovery integration test that exercised the supervisor.DaemonServicenowextends EventEmitterand emitsstate/audioLevelinstead of pushing over a socket.src/app/main.tsruns the daemon in-process, creates the overlay window, and forwards state to the renderer.src/app/command-server.tsbinds the unix command socket for payload-carrying CLI verbs (soniox-toggle) and doubles as the single-instance guard.src/cli/app-launcher.tslocates the bundle + Electron binary and spawns the app (strippingELECTRON_OZONE_PLATFORM_HINTso the window comes up as an XWayland client).togglelazily respawns a dead app before delivering the trigger (crash recovery).installprints Hyprlandexec-onceguidance instead of writing a systemd unit.import.meta.dirwithfileURLToPath-based resolution so the daemon runs under Electron/Node.scripts/build-app.tsesbuild-bundles the app todist/app, writingpackage.jsonname: hyprvox-overlayso the window's WM_CLASS ishyprvox-overlay(Hyprland rules target that class). Addssrc/app/tsconfig.jsonandtypecheck/build:appscripts.ARCHITECTURE,CLI_COMMANDS,AGENTS.Window identity & XWayland (why it matters)
The overlay parks off-screen via client self-positioning, which native Wayland forbids. The app pins
--ozone-platform=x11and the launcher stripsELECTRON_OZONE_PLATFORM_HINT(the session sets it toauto, which Electron consumes before JS runs and which otherwise suppresses the window entirely). WM_CLASS must behyprvox-overlayor Hyprland rules don't match.Verification
groq+deepgram,exact_matchmerge (confidence 1), transcript reached clipboard + history.Out of scope (follow-ups)
decideMerge→utilityProcess) intentionally deferred until the ongoing long-uptime overlay-slowdown investigation reports, so the diagnosis drives the fix rather than a guess.electronstill sits under overlaydevDependenciesthough it's now the production runtime — worth reclassifying separately.Summary by CodeRabbit
New Features
Bug Fixes
Documentation