The desktop workspace ships the Claude Code Agent Monitor dashboard as a
native macOS .app (distributed as a .dmg). It is an Electron shell that
embeds the existing Express server in-process and renders the already-built
React client in a BrowserWindow.
One-line mental model: Electron is a window onto the same code. The desktop app does not reimplement the dashboard — it
require()sserver/index.jsdirectly, in the same Node runtime as the Electron main process, and points a Chromium window at it.
For the user-facing guide (download, install, Gatekeeper, tray menu,
auto-start) see ../DESKTOP.md. This file is the
contributor / architecture reference.
- TL;DR
- Where the desktop app sits
- Process model
- Boot lifecycle
- Server hosting & port discovery
better-sqlite3native-module handling- Background services & hook bootstrap
- Window, tray & menu
- Auto-start (Login Items)
- Source tree
- Packaged app layout
- Build pipeline
- Commands
- Build performance — read this
- Code signing & notarization
- Continuous integration
- Smoke test
- Environment variables
- Logs & troubleshooting
- What this workspace does not touch
# From the repo root:
npm run setup # install root + client deps, build client, install hooks
npm run build # build client/dist (the SPA the Electron window loads)
npm run desktop:install # install Electron, electron-builder, types into desktop/
npm run desktop:dev # tsc → launch Electron pointing at out/main.js
npm run desktop:test # smoke test (spawn Electron + probe /api/health)
# Build a DMG:
npm run desktop:dmg # universal (x64 + arm64) — correct for release, SLOW
npm run desktop:dmg:arm64 # Apple Silicon only — fast, for your own machine
npm run desktop:dmg:x64 # Intel only — fast
⚠️ The universal build is intentionally slow (it builds the app twice and merges the two architectures). For running on your own Mac, use the arch-specific command. See Build performance.
desktop/ is a sibling workspace — not a npm-workspaces conversion. It has
its own package.json, its own node_modules, and its own toolchain. It
consumes the rest of the repo as plain files.
flowchart TD
subgraph repo["Claude-Code-Agent-Monitor (repo root)"]
server["server/<br/>Express API · SQLite · WebSocket"]
client["client/<br/>React + Vite SPA"]
scripts["scripts/<br/>hook installer/handler, import, seed"]
mcp["mcp/<br/>local MCP server"]
vscode["vscode-extension/"]
desktop["desktop/<br/>★ Electron shell (this workspace)"]
end
desktop -- "require() in-process" --> server
desktop -- "loads built SPA from" --> client
desktop -- "auto-installs hooks via" --> scripts
server -- "serves static" --> client
style desktop fill:#1f6feb,stroke:#1158c7,color:#fff
style server fill:#238636,stroke:#196c2e,color:#fff
The desktop app touches no other workspace's runtime behavior. The only
change outside desktop/ is a behavior-preserving refactor of
server/index.js (see the last section).
Electron runs a main process (Node.js) and one or more renderer processes (Chromium). In this app:
- The main process hosts the embedded Express server and manages the window, tray, and menus. There is no child process and no IPC for the server — it runs inside the main process's own event loop.
- The renderer is just Chromium loading
http://127.0.0.1:<port>— exactly the same origin a normal browser would. Thepreload.tsis intentionally empty, so the renderer has zero privileged surface.
flowchart LR
subgraph main["Electron Main Process (Node 22 / Electron 35)"]
boot["main.ts<br/>lifecycle"]
host["server-host.ts<br/>embedded server"]
express["server/index.js<br/>Express + WS + SQLite"]
tray["tray.ts"]
menu["menu.ts"]
host --> express
boot --> host
boot --> tray
boot --> menu
end
subgraph renderer["Renderer Process (Chromium)"]
win["BrowserWindow<br/>React dashboard"]
preload["preload.ts<br/>(empty — no bridge)"]
end
express -- "http + ws on 127.0.0.1:port" --> win
win -.->|loads| preload
hooks["Claude Code hooks<br/>(separate node processes)"] -- "POST /api/hooks/event" --> express
style main fill:#0d1117,stroke:#30363d,color:#e6edf3
style renderer fill:#161b22,stroke:#30363d,color:#e6edf3
sequenceDiagram
autonumber
participant OS as macOS
participant Main as main.ts
participant Host as server-host.ts
participant Srv as server/index.js
participant UI as BrowserWindow
OS->>Main: launch app
Main->>Main: requestSingleInstanceLock()
alt lock not acquired
Main->>OS: exit(0) — focus existing instance
end
Main->>Host: startEmbeddedServer()
Host->>Host: probe port 4820 — adopt if a healthy server answers
alt no server to adopt
Host->>Host: pickFreePort() · patch better-sqlite3 ABI
Host->>Srv: require() · createApp() · startServer(port)
Host->>Srv: waitForHealthy() — poll /api/health
Host->>Srv: bootstrapOwnedServer() — schedulers, cc-watcher, install hooks
end
Host-->>Main: ServerHandle { url, port, ownedByUs, stop }
Main->>Main: installApplicationMenu() · createTray()
alt launched at login
Main->>OS: stay tray-only, hide dock
else normal launch
Main->>UI: createDashboardWindow(url)
UI->>Srv: GET http://127.0.0.1:port
end
Note over Main: window "close" → hide (server keeps running)
Note over Main: before-quit → stop server + closeEmbeddedDatabase()
Key behaviors:
| Event | Behavior |
|---|---|
| Second launch | requestSingleInstanceLock() fails → the new process exits and the existing window is focused. |
| Window close | Intercepted — the window hides, the server and tray keep running. |
window-all-closed |
App stays alive (tray-only mode). |
| Launched at login | The dashboard window is not shown — only the tray icon. |
before-quit |
If we own the server: stop the HTTP server, then closeEmbeddedDatabase() for a clean WAL checkpoint, then app.exit(0). |
server-host.ts is the only file that imports server/index.js. It picks
a port, boots the server, and returns a ServerHandle.
flowchart TD
start["startEmbeddedServer()"] --> forced{"CCAM_DESKTOP_BIND_PORT set?"}
forced -->|yes| bind["bind exactly that port<br/>(no adoption, no fallback)"]
forced -->|no| adopt{"healthy server<br/>already on :4820?"}
adopt -->|yes| reuse["adopt it<br/>ownedByUs = false"]
adopt -->|no| pick["pickFreePort()"]
pick --> p1{":4820 free?"}
p1 -->|yes| use4820["use 4820"]
p1 -->|no| p2{"any of<br/>:4821–:4829 free?"}
p2 -->|yes| usefb["use that"]
p2 -->|no| p3{"any of<br/>:49152–:49500 free?"}
p3 -->|yes| userand["use that"]
p3 -->|no| fail["throw — no free port"]
bind --> boot["createApp() + startServer()"]
use4820 --> boot
usefb --> boot
userand --> boot
boot --> healthy["waitForHealthy()<br/>poll /api/health ≤ 30s"]
healthy --> bg["bootstrapOwnedServer()"]
bg --> handle["ServerHandle ownedByUs = true"]
reuse --> handleR["ServerHandle ownedByUs = false"]
style reuse fill:#9e6a03,stroke:#7d5300,color:#fff
style fail fill:#da3633,stroke:#b62324,color:#fff
Adoption — probePort() connects, then checks that the listener answers
GET /api/health with { status: "ok" }. If a healthy dashboard server is
already on :4820 (e.g. you ran npm start in a terminal), the desktop app
adopts it rather than double-binding. An adopted server is not owned by
the app — quitting the app leaves it running.
ServerHandle:
interface ServerHandle {
url: string; // e.g. "http://127.0.0.1:4820"
port: number;
ownedByUs: boolean; // false when adopted
stop: () => Promise<void>;
}Hook port discovery — because the embedded server may bind a fallback port
(4821+) when 4820 is taken, the Claude Code hook handler must not assume 4820.
On startup the server writes its live port to ~/.claude/.agent-dashboard.json
(server/lib/server-info.js); scripts/hook-handler.js reads that file to
target the running server. Without this, hook events would be POSTed to 4820 —
nothing would receive them and the dashboard would stay empty.
better-sqlite3 is the only native module in the dependency tree, and a
native module must be compiled against the exact Node ABI it runs on. The repo
root's copy is built for the system Node (so npm run test:server works);
Electron ships its own Node ABI.
The desktop workspace solves this without disturbing the root install:
flowchart TD
subgraph desk["desktop/node_modules"]
d1["better-sqlite3<br/>rebuilt for Electron's ABI<br/>(by electron-builder install-app-deps)"]
end
subgraph root["node_modules (repo root)"]
r1["better-sqlite3<br/>built for system Node<br/>(used by npm run test:server)"]
end
patch["ensureNativeModulesPatched()<br/>overrides Module._resolveFilename"]
srv["server/db.js<br/>require('better-sqlite3')"]
srv -->|"request intercepted"| patch
patch -->|"redirected to"| d1
patch -.->|"everything else<br/>passes through"| root
style d1 fill:#238636,stroke:#196c2e,color:#fff
style patch fill:#1f6feb,stroke:#1158c7,color:#fff
- The patch is process-local and installed exactly once, before
server/index.jsisrequire()d. - It rewrites only
require("better-sqlite3"); every other module resolves normally. electron-builder.ymltherefore excludes the rootbetter-sqlite3from the bundle (it would trip@electron/universal's identical-file detector) andasarUnpacks the desktop copy (native.nodefiles cannot live inside anasararchive).- PR #37's
compat-sqlite(node:sqlite) fallback remains as a safety net — one reason the desktop app pins Electron 35 (its bundled Node 22.16 hasnode:sqlite; Electron 31's Node 20 did not).
node server/index.js runs its production bootstrap from an
if (require.main === module) block. Because the desktop app require()s
that module, the block never fires — so the bootstrap was extracted into an
exported startBackgroundServices() that both paths call.
flowchart LR
subgraph standalone["node server/index.js"]
s1["require.main === module"] --> s2["startBackgroundServices()"]
end
subgraph desktopapp["desktop app"]
d1["server-host.ts<br/>bootstrapOwnedServer()"] --> d2["startBackgroundServices()"]
d1 --> d3["installHooks()"]
end
d2 --> svc
s2 --> svc
subgraph svc["Background services"]
u["update scheduler"]
w["cc-watcher (Claude config watcher)"]
r["orphaned-run reconciliation"]
end
style d1 fill:#1f6feb,stroke:#1158c7,color:#fff
bootstrapOwnedServer() runs once (guarded by a module-level flag so a
Restart Server does not double-register schedulers/watchers) and:
- Calls
startBackgroundServices()— the update scheduler, thecc-watcherconfig watcher, and one-time orphaned-run reconciliation. - Calls
installHooks()— writes the Claude Code hook configuration to~/.claude/settings.json, so a DMG-only user gets events flowing without ever runningnpm run install-hooksfrom a checkout.
It runs only when the server is owned by the app — an adopted server has already done its own bootstrap.
flowchart TD
tray["Menu-bar (tray) icon"]
tray -->|left-click| toggle["toggle dashboard window"]
tray -->|right-click| menu["context menu (built fresh)"]
menu --> m1["Open Dashboard"]
menu --> m2["Open in Browser…"]
menu --> m3["Restart Server"]
menu --> m4["Show Logs"]
menu --> m5["Open at Login ☑"]
menu --> m6["Quit"]
win["BrowserWindow"]
win -->|"close"| hide["hide() — server stays up"]
win -->|"resize / move"| persist["debounced save →<br/>userData/window-state.json"]
win -->|"external link"| ext["shell.openExternal()"]
style tray fill:#1f6feb,stroke:#1158c7,color:#fff
- Tray — the always-on surface. Left-click toggles the window; right-click
pops the context menu. The menu is rebuilt on each open so the port label and
Open at Login checkbox are always current. (The tray deliberately does
not use
setContextMenu, which on macOS would make a left-click open the menu and collide with the toggle behavior.) - Window —
BrowserWindowwithcontextIsolation: true,nodeIntegration: false, an empty preload, andwebSecurity: true. Geometry is persisted towindow-state.jsonunderapp.getPath('userData'). External links open in the system browser, never inside Electron. - Application menu — standard macOS menu (
About,Open at Login,File,Edit,View,Window,Help).⌘Ris owned byView ▸ reload.
Auto-start uses Electron's first-party app.setLoginItemSettings — which wraps
the modern macOS SMAppService / ServiceManagement framework — not a
LaunchAgent plist. The toggle therefore appears in
System Settings → General → Login Items where users expect to manage it.
stateDiagram-v2
[*] --> Disabled
Disabled --> Enabled: tray / menu "Open at Login"
Enabled --> Disabled: toggle again
Enabled --> LaunchedAtLogin: macOS login
LaunchedAtLogin --> TrayOnly: window hidden,<br/>dock hidden
TrayOnly --> WindowShown: user clicks tray
When macOS launches the app at login (wasOpenedAtLogin), it starts
tray-only with openAsHidden: true — no window jumps into the user's face.
desktop/
├── src/
│ ├── main.ts # main process entry — lifecycle, dialogs, wiring
│ ├── server-host.ts # ★ in-process Express boot, port discovery, adoption,
│ │ # better-sqlite3 ABI patch, DB + discovery-file close,
│ │ # getServerSnapshot() for the tray dropdown
│ ├── window.ts # BrowserWindow + persisted geometry; native macOS
│ │ # titleBarStyle: 'default' (clear traffic-light row)
│ ├── menu.ts # native application menu
│ ├── tray.ts # menu-bar icon + single-click dropdown w/ live
│ │ # {sessions, agents, events-today} snapshot
│ ├── login-item.ts # macOS Login Items (SMAppService)
│ ├── shell-path.ts # recover the user's shell PATH (so `claude` is found)
│ ├── logger.ts # file logger → app.getPath('logs')/desktop.log
│ ├── constants.ts # APP_NAME, ports, timeouts, window size
│ └── preload.ts # intentionally empty (zero renderer privilege)
├── scripts/
│ ├── prebuild.js # ensures client/dist + root node_modules exist
│ ├── notarize.js # electron-builder afterSign hook (opt-in)
│ └── build-icons.sh # regenerate icon.icns + tray PNGs from SVG
├── assets/ # icon.icns, icon.png, tray-icon-Template*.png, SVGs
├── tests/
│ └── smoke.test.mjs # spawn Electron + probe /api/health
├── electron-builder.yml # DMG packaging config
├── tsconfig.json # strict; src/ → out/
└── package.json
Compiled output lands in desktop/out/ (git-ignored); packaged artifacts in
desktop/release/ (git-ignored).
electron-builder produces Claude Code Monitor.app. The Electron main
process code is packed into app.asar; the rest of the repo is shipped as
extraResources (plain files under Resources/app/):
flowchart TD
app["Claude Code Monitor.app"]
app --> contents["Contents/"]
contents --> macos["MacOS/ — Electron binary"]
contents --> res["Resources/"]
res --> asar["app.asar<br/>(compiled out/**, package.json)"]
res --> unpacked["app.asar.unpacked/<br/>node_modules/better-sqlite3 (.node)"]
res --> appdir["app/"]
appdir --> a1["server/ — Express server (no tests)"]
appdir --> a2["client/dist/ — built React SPA"]
appdir --> a3["scripts/ — hook-handler, install-hooks"]
appdir --> a4["node_modules/ — server runtime deps"]
appdir --> a5["package.json"]
style asar fill:#1f6feb,stroke:#1158c7,color:#fff
style appdir fill:#238636,stroke:#196c2e,color:#fff
At runtime server-host.ts resolves this root: process.resourcesPath/app
when packaged, or the repo root in development.
flowchart LR
src["src/*.ts"] -->|prebuild guard| pre["scripts/prebuild.js<br/>verify client/dist + node_modules"]
pre --> tsc["tsc → out/*.js"]
tsc --> eb["electron-builder"]
eb --> dl["download Electron runtime"]
eb --> rebuild["@electron/rebuild<br/>better-sqlite3 per arch"]
eb --> asar["pack out/** → app.asar"]
eb --> extra["copy server/ client/dist/ scripts/ node_modules/<br/>→ Resources/app/"]
asar --> appbundle[".app bundle"]
extra --> appbundle
rebuild --> appbundle
appbundle --> sign["codesign (ad-hoc by default)"]
sign --> notarize["notarize (opt-in, afterSign hook)"]
notarize --> dmg["hdiutil → .dmg"]
style tsc fill:#1f6feb,stroke:#1158c7,color:#fff
style dmg fill:#238636,stroke:#196c2e,color:#fff
A universal build runs the packaging → rebuild → sign steps twice
(once per architecture), then @electron/universal merges the two app trees
into a single fat binary before the DMG step.
All commands are runnable from the repo root (desktop:*) or from inside
desktop/. Every script that packages first runs npm run build, so you never
need to invoke electron-builder bare (doing so skips the TypeScript compile
and fails with "entry file out/main.js does not exist").
| Repo-root command | desktop/ command |
What it does |
|---|---|---|
npm run desktop:install |
npm install |
Install Electron, electron-builder, types; rebuild better-sqlite3 for Electron's ABI (postinstall). |
npm run desktop:build |
npm run build |
Prebuild guard + tsc → out/. |
npm run desktop:dev |
npm run dev |
Build, then launch Electron against out/main.js. |
npm run desktop:test |
npm test |
Build, then run the smoke test. |
npm run desktop:dmg |
npm run dmg |
Universal DMG (x64 + arm64). Correct for release. Slow. |
npm run desktop:dmg:arm64 |
npm run dmg:arm64 |
Apple-Silicon-only DMG. Fast. |
npm run desktop:dmg:x64 |
npm run dmg:x64 |
Intel-only DMG. Fast. |
| — | npm run build:icons |
Regenerate icon.icns + tray PNGs from the SVGs. |
| — | npm run clean |
Remove out/ and release/. |
After
npm run cleanyou mustnpm run buildagain before packaging —cleandeletesout/, andelectron-builderonly packages, it does not compile. Thedmg*scripts chain the build for you; a bareelectron-buildercall does not.
DMG builds can be very slow. This is expected — it is the standard Electron packaging cost, multiplied by the universal merge:
flowchart TD
u["npm run desktop:dmg (universal)"] --> b1["build full x64 app tree"]
u --> b2["build full arm64 app tree"]
b1 --> merge["@electron/universal merge<br/>walk both trees · lipo every native binary · dedup"]
b2 --> merge
merge --> sign["ad-hoc sign every binary"]
sign --> dmgstep["hdiutil → .dmg"]
a["npm run desktop:dmg:arm64 (single arch)"] --> sb["build one app tree"]
sb --> ssign["sign"]
ssign --> sdmg["hdiutil → .dmg"]
style u fill:#9e6a03,stroke:#7d5300,color:#fff
style a fill:#238636,stroke:#196c2e,color:#fff
Why universal is slow:
- Everything happens twice — electron-builder builds a full x64 app tree
and a full arm64 app tree, then
@electron/universalwalks both andlipos every native binary into a fat binary. - The app tree is large — the server's entire production dependency tree
(
express,swagger-ui-express,ws, …) ships asextraResources; that's tens of thousands of files, walked and copied for each architecture. - Per-binary code signing runs over the whole merged bundle.
Net effect: a ~250 MB app is built, copied, and signed several times over —
gigabytes of disk I/O. The Electron runtime downloads (~110 MB each) are not
the bottleneck; the silent packaging arch=universal merge is.
Guidance:
- Building for your own Mac → use
desktop:dmg:arm64(Apple Silicon) ordesktop:dmg:x64(Intel). One architecture, no merge — finishes in roughly a minute instead of many. - Building a release artifact for everyone → use the universal
desktop:dmgand expect it to take a while. CI builds the universal DMG and uploads it as theClaudeCodeMonitor-dmgartifact, so you rarely need to build it locally. - The bundle is ~80 MB DMG / ~250 MB on disk regardless — the standard Electron tax.
The DMG is ad-hoc signed by default so anyone can build a working .dmg
without a paid Apple Developer account.
- The
packagescript setsCSC_IDENTITY_AUTO_DISCOVERY=falseso a code-signing certificate already in the contributor's macOS keychain is never picked up. (Without this, electron-builder auto-discovers such a cert and attemptstype=distributionsigning, which fails on a non–Developer ID cert with "Application … could not be found".) - Real Developer ID signing activates when
CSC_LINK(a base64-encoded.p12) andCSC_KEY_PASSWORDare provided —CSC_LINKis an explicit certificate and is unaffected by the auto-discovery flag. - Notarization is opt-in:
desktop/scripts/notarize.js(anelectron-builderafterSignhook) runs only whenAPPLE_ID,APPLE_TEAM_ID, andAPPLE_APP_SPECIFIC_PASSWORDare all set. Otherwise it is a no-op.
flowchart TD
build["DMG build"] --> q{"CSC_LINK set?"}
q -->|yes| real["sign with Developer ID cert"]
q -->|no| adhoc["ad-hoc sign<br/>(keychain scan disabled)"]
real --> n{"APPLE_ID + TEAM_ID + PASSWORD set?"}
adhoc --> n
n -->|yes| notar["notarize via notarytool"]
n -->|no| skip["skip notarization"]
notar --> out[".dmg"]
skip --> out
style adhoc fill:#9e6a03,stroke:#7d5300,color:#fff
style real fill:#238636,stroke:#196c2e,color:#fff
An ad-hoc DMG triggers a Gatekeeper warning on first launch. The one-line
workaround is in ../DESKTOP.md:
xattr -cr "/Applications/Claude Code Monitor.app".
The 🍎 macOS Desktop (DMG) job in .github/workflows/ci.yml:
flowchart LR
ch["changes job<br/>dorny/paths-filter"] -->|"desktop/** changed?"| gate{run?}
push["push to any branch"] --> gate
label["PR has 'desktop' label"] --> gate
gate -->|yes| job["desktop job (macos-latest)"]
job --> j1["npm ci (root, client, desktop)"]
j1 --> j2["tsc build"]
j2 --> j3["smoke test"]
j3 --> j4["build universal DMG<br/>(retry on flaky hdiutil detach)"]
j4 --> j5["upload ClaudeCodeMonitor-dmg artifact"]
j5 --> rel["release job (master only)<br/>publish vX.Y.Z if new"]
style job fill:#1f6feb,stroke:#1158c7,color:#fff
style rel fill:#238636,stroke:#1a6e2c,color:#fff
- The job is path-filtered — a
changesjob (dorny/paths-filter) detectsdesktop/**edits; the desktop job also runs on anypushor when a PR carries thedesktoplabel. - DMG build resilience —
electron-builderfinalizes the DMG withhdiutil detach, which is intermittently flaky on GitHub macOS runners. The step disables Spotlight indexing and retries the build up to 3 times, force-detaching any stale volume between attempts. - The built DMG is uploaded as the
ClaudeCodeMonitor-dmgartifact (downloadable from the workflow run). - On
master, a follow-onreleasejob reads the version frompackage.jsonand publishesvX.Y.Zas a GitHub Release with the DMG attached — but only when no release exists for that version yet, so bumping the version is what cuts a release. The result is a permanent, anonymous download URL atreleases/latest.
tests/smoke.test.mjs is intentionally minimal — it proves the embedded server
boots, without needing a display (so CI needs no xvfb).
sequenceDiagram
autonumber
participant T as smoke.test.mjs
participant E as Electron (out/main.js)
participant S as embedded server
T->>T: pick a unique high port
T->>E: spawn with CCAM_DESKTOP_BIND_PORT=<port>
E->>S: startEmbeddedServer() — bind exactly <port>
loop until healthy or 60s
T->>S: GET /api/health
end
T->>T: assert status == "ok" AND <port> matched
T->>T: assert Electron process still alive
T->>E: SIGTERM
CCAM_DESKTOP_BIND_PORT forces the server onto an exact port (no adoption, no
fallback) so the test can be certain it probed this process and not an
unrelated server on :4820.
| Variable | Used by | Effect |
|---|---|---|
CCAM_DESKTOP_BIND_PORT |
server-host.ts |
Bind exactly this port — disables adoption and fallback. Used by the smoke test. |
CCAM_DESKTOP_NO_ADOPT |
server-host.ts |
=1 → never adopt an existing :4820 server; always start our own. |
CCAM_DESKTOP_VERBOSE |
logger.ts |
Mirror info/warn log lines to stdout (errors always go to stderr). |
DASHBOARD_DATA_DIR |
server-host.ts → server |
Set automatically to app.getPath('userData')/data so the SQLite database and VAPID keys live in the per-user Application Support directory, never inside the (possibly read-only) .app bundle. |
CSC_IDENTITY_AUTO_DISCOVERY |
electron-builder | Set to false by the package script — forces ad-hoc signing. |
CSC_LINK / CSC_KEY_PASSWORD |
electron-builder | Explicit Developer ID .p12 for real signing. |
APPLE_ID / APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD |
notarize.js |
Enable Apple notarization when all three are set. |
The embedded server also honors the dashboard's own env vars (DASHBOARD_PORT
and DASHBOARD_DATA_DIR are set automatically by server-host.ts; everything
else in ../SETUP.md applies).
Writable state never lives in the
.appbundle. A packaged, code-signed, or app-translocated bundle is read-only; a database written there would break History Import and event persistence.server-host.tspointsDASHBOARD_DATA_DIRat~/Library/Application Support/Claude Code Monitor/data/, which is also why your imported history survives an app reinstall or update.
The Electron main process has no console when launched from Finder, so
logger.ts writes to a per-user file:
~/Library/Logs/Claude Code Monitor/desktop.log
Reach it from the tray menu → Show Logs.
| Symptom | Cause / fix |
|---|---|
entry file out/main.js does not exist |
You ran electron-builder without building first. Run npm run build (or use a dmg* script). |
Signing fails: Application … could not be found after retries |
A keychain cert was auto-discovered. The package script now sets CSC_IDENTITY_AUTO_DISCOVERY=false; ensure you build via npm run dmg*, not bare electron-builder. |
DMG build hangs on packaging arch=universal |
Not hung — the universal merge is slow. See Build performance. Use dmg:arm64 / dmg:x64 for speed. |
hdiutil detach … exit code 1 in CI |
Flaky GitHub runner; the CI step already retries with Spotlight disabled. Re-run the job if it still fails. |
| Dashboard window is blank | The embedded server failed /api/health within 30 s — check desktop.log. |
| Gatekeeper blocks the app | Ad-hoc DMG. xattr -cr "/Applications/Claude Code Monitor.app". |
| Hooks not firing | The app installs hooks on first owned-server boot; start a new Claude Code session afterwards. Verify entries in ~/.claude/settings.json. |
"Run Claude" says claude isn't on your PATH |
shell-path.ts recovers the login-shell PATH at startup. If claude is a shell alias or function (not a real binary), it cannot be spawned — install the claude CLI as an executable. Check desktop.log for the user PATH resolved line. |
desktop:dev / desktop:test fail with ERR_DLOPEN_FAILED |
A prior DMG build left better-sqlite3 built for the other CPU arch. prebuild.js auto-heals this on the next build; if needed, run npm run desktop:install. |
| Imported history disappeared after reinstall | Fixed — the database now lives in ~/Library/Application Support/Claude Code Monitor/data/, outside the bundle. A one-time gap exists only across the upgrade from a build that predated this fix; re-run Import History → Rescan. |
By design, changes outside desktop/ are kept to a minimum:
server/index.js— its post-listen bootstrap was extracted into an exportedstartBackgroundServices()so the embedded server boots the same one-time legacy-session import, update scheduler,cc-watcher, and orphaned-run reconciliation thatnode server/index.jsdoes. A behavior-preserving refactor — the standalone server path is functionally unchanged. (The legacy-session import previously lived in therequire.main === moduleblock, so the embedded server never ran it and the desktop dashboard started empty; moving it intostartBackgroundServices()fixes that.) The server also publishes its live port on startup.server/lib/server-info.js(new) — multi-server discovery file at~/.claude/.agent-dashboard.json. Every running dashboard appends its{port, pid, startedAt}entry on startup, removes it on clean shutdown, and stale entries are pruned by aprocess.kill(pid, 0)liveness check on read. ExposeswriteServerInfo,removeServerInfo,resolveAllDashboardPorts(fan-out targets), and the legacy single-portresolveDashboardPort. The file also carries legacy root-levelport/pid/startedAtfields populated from the most recently started live server, so older hook handlers bundled inside an already-installed.appstill resolve to a reachable port.scripts/hook-handler.js—Promise.allfan-out of every hook payload to every live server returned byresolveAllDashboardPorts()(CLAUDE_DASHBOARD_PORToverrides to a single target). This is what lets the desktop app coexist withnpm run dev— both dashboards receive every event and both stay real-time.server/lib/push.js—sendPushToAll()now also fires a native Electron notification whenprocess.versions.electronis set, so the desktop app surfaces notifications via the OS API instead of relying on Web Push (which fails inside Electron — no FCM credentials in the Chromium build). The standalone server path is unchanged: the native leg is a no-op there, and Web Push delivers as before.scripts/dev.js(new) —npm run dev's entry point. Probes both127.0.0.1and::1for a free port in4820–4859(so an SSHLocalForwardwith loopback-specific binds can't shadow Node's wildcard listen), exportsDASHBOARD_PORT, then spawns the existingconcurrentlyserver + client pipeline.npm run dev:rawbypasses it for parity with the old behaviour.
client/, mcp/, and vscode-extension/ are untouched. If you find
yourself wanting to edit those, that belongs in a separate PR.
User-facing docs: ../DESKTOP.md · Project architecture:
../ARCHITECTURE.md · Setup: ../SETUP.md