feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,833 @@
|
||||
# `desktop/` — Native macOS App (Electron)
|
||||
|
||||
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()`s
|
||||
> `server/index.js` directly, 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`](../DESKTOP.md). This file is the
|
||||
**contributor / architecture** reference.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [TL;DR](#tldr)
|
||||
- [Where the desktop app sits](#where-the-desktop-app-sits)
|
||||
- [Process model](#process-model)
|
||||
- [Boot lifecycle](#boot-lifecycle)
|
||||
- [Server hosting & port discovery](#server-hosting--port-discovery)
|
||||
- [`better-sqlite3` native-module handling](#better-sqlite3-native-module-handling)
|
||||
- [Background services & hook bootstrap](#background-services--hook-bootstrap)
|
||||
- [Window, tray & menu](#window-tray--menu)
|
||||
- [Auto-start (Login Items)](#auto-start-login-items)
|
||||
- [Source tree](#source-tree)
|
||||
- [Packaged app layout](#packaged-app-layout)
|
||||
- [Build pipeline](#build-pipeline)
|
||||
- [Commands](#commands)
|
||||
- [Build performance — read this](#build-performance--read-this)
|
||||
- [Code signing & notarization](#code-signing--notarization)
|
||||
- [Continuous integration](#continuous-integration)
|
||||
- [Smoke test](#smoke-test)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Logs & troubleshooting](#logs--troubleshooting)
|
||||
- [What this workspace does *not* touch](#what-this-workspace-does-not-touch)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
# 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 (macOS):
|
||||
npm run desktop:dmg # both per-arch DMGs (arm64 + x64) — correct for release, SLOWER
|
||||
npm run desktop:dmg:arm64 # Apple Silicon only — fast, for your own machine
|
||||
npm run desktop:dmg:x64 # Intel only — fast
|
||||
|
||||
# Build a Windows .exe (run on Windows x64):
|
||||
npm run desktop:win # NSIS installer → release/ClaudeCodeMonitor-Setup-<ver>-x64.exe
|
||||
npm run desktop:win:portable # no-install portable → release/ClaudeCodeMonitor-<ver>-x64-portable.exe
|
||||
```
|
||||
|
||||
> ⚠️ `desktop:dmg` is slower because it builds the app **twice** — once per
|
||||
> architecture — and emits **two** per-arch DMGs (`arm64` + `x64`). It does not
|
||||
> merge them into a universal binary. For running on your own Mac, use the
|
||||
> arch-specific command. See [Build performance](#build-performance--read-this).
|
||||
|
||||
> 🪟 **Windows builds run on Windows** (DMGs build on macOS). `desktop:win`
|
||||
> produces an **unsigned** installer — fine to run; SmartScreen may show a
|
||||
> "More info → Run anyway" prompt on first launch. The icon (`assets/icon.ico`)
|
||||
> is generated from `assets/icon.png` by `npm run build:win-icon`
|
||||
> (PowerShell + .NET, no extra tooling). `better-sqlite3` is fetched as a
|
||||
> prebuilt Electron binary by `npm run desktop:install`, so no Visual Studio
|
||||
> C++ toolchain is required for the common case. If that fetch/rebuild *does*
|
||||
> fail (no C++ toolchain, or a Node version with no prebuilt binary),
|
||||
> `npm run desktop:install` — and any `desktop:*` build, gated by `prebuild.js` —
|
||||
> prints the exact per-OS prerequisite (Windows: Visual Studio Build Tools with
|
||||
> the "Desktop development with C++" workload; macOS: `xcode-select --install`;
|
||||
> Linux: build-essential + python3) plus a no-toolchain alternative, then exits
|
||||
> non-zero rather than crashing at runtime:
|
||||
>
|
||||
> ```bash
|
||||
> cd desktop
|
||||
> npm install --ignore-scripts
|
||||
> node node_modules/electron/install.js
|
||||
> npx electron-builder install-app-deps
|
||||
> ```
|
||||
>
|
||||
> A Node LTS (20/22) ships prebuilt binaries and avoids the compile entirely.
|
||||
|
||||
---
|
||||
|
||||
## Where the desktop app sits
|
||||
|
||||
`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.
|
||||
|
||||
```mermaid
|
||||
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](#what-this-workspace-does-not-touch)).
|
||||
|
||||
---
|
||||
|
||||
## Process model
|
||||
|
||||
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. The `preload.ts` is intentionally
|
||||
empty, so the renderer has zero privileged surface.
|
||||
|
||||
```mermaid
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Boot lifecycle
|
||||
|
||||
```mermaid
|
||||
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 hosting & port discovery
|
||||
|
||||
`server-host.ts` is the **only file** that imports `server/index.js`. It picks
|
||||
a port, boots the server, and returns a `ServerHandle`.
|
||||
|
||||
```mermaid
|
||||
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`:**
|
||||
|
||||
```ts
|
||||
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` native-module handling
|
||||
|
||||
`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:
|
||||
|
||||
```mermaid
|
||||
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.js` is `require()`d.
|
||||
- It rewrites *only* `require("better-sqlite3")`; every other module resolves
|
||||
normally.
|
||||
- `electron-builder.yml` therefore **excludes** the root `better-sqlite3` from
|
||||
the bundle (it would trip `@electron/universal`'s identical-file detector)
|
||||
and `asarUnpack`s the desktop copy (native `.node` files cannot live inside
|
||||
an `asar` archive).
|
||||
- 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 has
|
||||
`node:sqlite`; Electron 31's Node 20 did not).
|
||||
- **No toolchain needed in the common case** — `npm run desktop:install` runs
|
||||
`scripts/install.js`, which wraps `npm install` (whose `postinstall` runs
|
||||
`electron-builder install-app-deps`). On success it behaves like a bare
|
||||
`npm install`. On failure — or if the native binary is missing afterward — it
|
||||
prints actionable help (`scripts/preflight.js`'s `printNativeDepHelp()`) and
|
||||
exits non-zero, never leaving a half-set-up `node_modules`. The help lists the
|
||||
per-OS C++ prerequisite (Windows: Visual Studio Build Tools with the "Desktop
|
||||
development with C++" workload; macOS: `xcode-select --install`; Linux:
|
||||
build-essential + python3), notes that a Node LTS (20/22) ships prebuilt
|
||||
binaries (avoiding the compile), and gives a no-toolchain alternative:
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install --ignore-scripts
|
||||
node node_modules/electron/install.js
|
||||
npx electron-builder install-app-deps
|
||||
```
|
||||
|
||||
`prebuild.js` runs the same check (`hasBetterSqliteBinary()`) before **every**
|
||||
`desktop:*` build/dev script and fails fast with the same help if the binary
|
||||
is missing — turning what was a runtime fatal-dialog crash into a build-time,
|
||||
copy-pasteable error.
|
||||
|
||||
---
|
||||
|
||||
## Background services & hook bootstrap
|
||||
|
||||
`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.
|
||||
|
||||
```mermaid
|
||||
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:
|
||||
|
||||
1. Calls `startBackgroundServices()` — the update scheduler, the `cc-watcher`
|
||||
config watcher, and one-time orphaned-run reconciliation.
|
||||
2. Calls `installHooks()` — writes the Claude Code hook configuration to
|
||||
`~/.claude/settings.json`, so a **DMG-only user gets events flowing**
|
||||
without ever running `npm run install-hooks` from a checkout.
|
||||
|
||||
It runs only when the server is **owned** by the app — an adopted server has
|
||||
already done its own bootstrap.
|
||||
|
||||
---
|
||||
|
||||
## Window, tray & menu
|
||||
|
||||
```mermaid
|
||||
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** — `BrowserWindow` with `contextIsolation: true`,
|
||||
`nodeIntegration: false`, an empty preload, and `webSecurity: true`. Geometry
|
||||
is persisted to `window-state.json` under `app.getPath('userData')`. External
|
||||
links open in the system browser, never inside Electron. Its `icon` is set to
|
||||
the colored app logo via `appIconPath()` (`icon.ico` on Windows, `icon.png`
|
||||
elsewhere — the same logo as the macOS Dock, rendered from `assets/icon.svg`),
|
||||
resolving dev vs packaged asset paths, so an unpackaged `desktop:dev` run shows
|
||||
the real logo in the title bar / taskbar instead of the generic Electron icon.
|
||||
macOS ignores `BrowserWindow#icon` (the dev Dock icon is set separately in
|
||||
`main.ts`; packaged apps get theirs from the bundle `.icns`/`.exe`).
|
||||
- **Application menu** — standard menu (`About`, `Open at Login`, `File`,
|
||||
`Edit`, `View`, `Window`, `Help`). `⌘R` / `Ctrl+R` is owned by `View ▸ reload`.
|
||||
The `File ▸ Open Dashboard` item (`⌘1`) is gated behind `isMac`: macOS keeps a
|
||||
global menu bar after the window hides so it can reopen it, but on
|
||||
Windows/Linux the menu is attached to the window and a menu accelerator can't
|
||||
fire while it's hidden — reopening there is the tray's *Open Dashboard*, and
|
||||
`focusOrCreateWindow` calls `show()` unconditionally so it reliably raises a
|
||||
backgrounded/minimized window (a bare `focus()` on Windows often only flashes
|
||||
the taskbar button).
|
||||
|
||||
---
|
||||
|
||||
## Auto-start (Login Items)
|
||||
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Source tree
|
||||
|
||||
```
|
||||
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,
|
||||
│ │ # /api/stats snapshot poller 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/
|
||||
│ ├── install.js # desktop:install wrapper: npm install + actionable
|
||||
│ │ # native-dep help on failure (exits non-zero)
|
||||
│ ├── preflight.js # shared hasBetterSqliteBinary() + printNativeDepHelp()
|
||||
│ ├── prebuild.js # ensures client/dist + root node_modules exist; fails
|
||||
│ │ # fast with setup help if better-sqlite3 binary missing
|
||||
│ ├── 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).
|
||||
|
||||
---
|
||||
|
||||
## Packaged app layout
|
||||
|
||||
`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/`):
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Build pipeline
|
||||
|
||||
```mermaid
|
||||
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
|
||||
```
|
||||
|
||||
`desktop:dmg` runs the *packaging → rebuild → sign → DMG* steps **twice**
|
||||
(once per architecture) and emits two separate DMGs (`…-arm64.dmg` +
|
||||
`…-x64.dmg`). There is no `@electron/universal` merge step — the release ships
|
||||
the two per-arch DMGs rather than one fat universal binary.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
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` | `node scripts/install.js` | Install Electron, electron-builder, types; rebuild `better-sqlite3` for Electron's ABI (`postinstall`). Preflights native deps — on failure (or a missing binary) prints per-OS setup help + a no-toolchain alternative and exits non-zero. |
|
||||
| `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` | **macOS:** both per-arch DMGs (arm64 + x64). Correct for release. **Slower.** |
|
||||
| `npm run desktop:dmg:arm64` | `npm run dmg:arm64` | **macOS:** Apple-Silicon-only DMG. **Fast.** |
|
||||
| `npm run desktop:dmg:x64` | `npm run dmg:x64` | **macOS:** Intel-only DMG. **Fast.** |
|
||||
| `npm run desktop:dmg:universal` | `npm run dmg:universal` | **macOS:** one merged universal DMG (arm64 + x86_64 via `@electron/universal`). Optional — not what the release ships. **Slowest.** |
|
||||
| `npm run desktop:win` | `npm run win` | **Windows:** NSIS installer `.exe` (x64). |
|
||||
| `npm run desktop:win:portable` | `npm run win:portable` | **Windows:** no-install portable `.exe` (x64). |
|
||||
| — | `npm run build:icons` | **macOS:** regenerate `icon.icns` + tray PNGs from the SVGs. |
|
||||
| — | `npm run build:win-icon` | **Windows:** regenerate `icon.ico` from `icon.png` (PowerShell + .NET). |
|
||||
| — | `npm run clean` | Remove `out/` and `release/`. |
|
||||
|
||||
> **After `npm run clean`** you must `npm run build` again before packaging —
|
||||
> `clean` deletes `out/`, and `electron-builder` only *packages*, it does not
|
||||
> compile. The `dmg*` scripts chain the build for you; a bare
|
||||
> `electron-builder` call does not.
|
||||
|
||||
---
|
||||
|
||||
## Build performance — read this
|
||||
|
||||
**`desktop:dmg` is slower than a single-arch build.** This is expected — it is
|
||||
the standard Electron packaging cost, paid **once per architecture**:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
u["npm run desktop:dmg (both arches)"] --> b1["build x64 app tree → sign → …-x64.dmg"]
|
||||
u --> b2["build arm64 app tree → sign → …-arm64.dmg"]
|
||||
|
||||
a["npm run desktop:dmg:arm64 (single arch)"] --> sb["build one app tree"]
|
||||
sb --> ssign["sign"]
|
||||
ssign --> sdmg["hdiutil → …-arm64.dmg"]
|
||||
|
||||
style u fill:#9e6a03,stroke:#7d5300,color:#fff
|
||||
style a fill:#238636,stroke:#196c2e,color:#fff
|
||||
```
|
||||
|
||||
Why `desktop:dmg` is slow:
|
||||
|
||||
1. **Everything happens twice** — electron-builder builds a full x64 app tree
|
||||
*and* a full arm64 app tree, rebuilding `better-sqlite3` and packaging a DMG
|
||||
for each. (There is no universal merge; each arch produces its own DMG.)
|
||||
2. **The app tree is large** — the server's entire production dependency tree
|
||||
(`express`, `swagger-ui-express`, `ws`, …) ships as `extraResources`; that's
|
||||
tens of thousands of files, walked and copied for each architecture.
|
||||
3. **Per-binary code signing** runs over each architecture's bundle.
|
||||
|
||||
Net effect: a ~250 MB app is built, copied, and signed once per architecture —
|
||||
gigabytes of disk I/O. The Electron runtime downloads (~110 MB each) are *not*
|
||||
the bottleneck; packaging two architectures back-to-back is.
|
||||
|
||||
**Guidance:**
|
||||
|
||||
- Building for **your own Mac** → use `desktop:dmg:arm64` (Apple Silicon) or
|
||||
`desktop:dmg:x64` (Intel). One architecture — finishes in roughly a minute
|
||||
instead of two.
|
||||
- Building the **release artifacts for everyone** → use `desktop:dmg` (builds
|
||||
both arches) and expect it to take about twice as long. CI runs `desktop:dmg`
|
||||
and uploads both DMGs as the `ClaudeCodeMonitor-dmg` artifact, so you rarely
|
||||
need to build them locally.
|
||||
- Each DMG is **~80 MB / ~250 MB on disk** — the standard Electron tax.
|
||||
|
||||
---
|
||||
|
||||
## Code signing & notarization
|
||||
|
||||
The DMG is **ad-hoc signed by default** so anyone can build a working `.dmg`
|
||||
without a paid Apple Developer account.
|
||||
|
||||
- The `package` script sets **`CSC_IDENTITY_AUTO_DISCOVERY=false`** so 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 attempts `type=distribution` signing, 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`) and `CSC_KEY_PASSWORD` are provided — `CSC_LINK` is an *explicit*
|
||||
certificate and is unaffected by the auto-discovery flag.
|
||||
- **Notarization** is opt-in: `desktop/scripts/notarize.js` (an
|
||||
`electron-builder` `afterSign` hook) runs only when `APPLE_ID`,
|
||||
`APPLE_TEAM_ID`, and `APPLE_APP_SPECIFIC_PASSWORD` are all set. Otherwise it
|
||||
is a no-op.
|
||||
|
||||
```mermaid
|
||||
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`](../DESKTOP.md):
|
||||
`xattr -cr "/Applications/Claude Code Monitor.app"`.
|
||||
|
||||
---
|
||||
|
||||
## Continuous integration
|
||||
|
||||
The `🍎 macOS Desktop (DMG)` job in `.github/workflows/ci.yml`:
|
||||
|
||||
```mermaid
|
||||
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 both per-arch DMGs<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 `changes` job (`dorny/paths-filter`)
|
||||
detects `desktop/**` edits; the desktop job also runs on any `push` or when a
|
||||
PR carries the `desktop` label.
|
||||
- **DMG build resilience** — `electron-builder` finalizes the DMG with
|
||||
`hdiutil 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-dmg`** artifact
|
||||
(downloadable from the workflow run).
|
||||
- On `master`, a follow-on **`release`** job reads the version from
|
||||
`package.json` and publishes `vX.Y.Z` as 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 at `releases/latest`.
|
||||
|
||||
---
|
||||
|
||||
## Smoke test
|
||||
|
||||
`tests/smoke.test.mjs` is intentionally minimal — it proves the embedded server
|
||||
boots, without needing a display (so CI needs no `xvfb`).
|
||||
|
||||
```mermaid
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| 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`](../SETUP.md) applies).
|
||||
|
||||
> **Writable state never lives in the `.app` bundle.** A packaged, code-signed,
|
||||
> or app-translocated bundle is read-only; a database written there would break
|
||||
> History Import and event persistence. `server-host.ts` points
|
||||
> `DASHBOARD_DATA_DIR` at `~/Library/Application Support/Claude Code Monitor/data/`,
|
||||
> which is also why your imported history survives an app reinstall or update.
|
||||
|
||||
---
|
||||
|
||||
## Logs & troubleshooting
|
||||
|
||||
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 seems slow | Not hung — `desktop:dmg` packages two architectures back-to-back. See [Build performance](#build-performance--read-this). Use `dmg:arm64` / `dmg:x64` for a single arch. |
|
||||
| `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**. |
|
||||
|
||||
---
|
||||
|
||||
## What this workspace does *not* touch
|
||||
|
||||
By design, changes outside `desktop/` are kept to a minimum:
|
||||
|
||||
- **`server/index.js`** — its post-listen bootstrap was extracted into an
|
||||
exported `startBackgroundServices()` so the embedded server boots the same
|
||||
one-time legacy-session import, update scheduler, `cc-watcher`, and
|
||||
orphaned-run reconciliation that `node server/index.js` does. A
|
||||
**behavior-preserving refactor** — the standalone server path is functionally
|
||||
unchanged. (The legacy-session import previously lived in the
|
||||
`require.main === module` block, so the embedded server never ran it and the
|
||||
desktop dashboard started empty; moving it into `startBackgroundServices()`
|
||||
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 a `process.kill(pid, 0)` liveness check on
|
||||
read. Exposes `writeServerInfo`, `removeServerInfo`,
|
||||
`resolveAllDashboardPorts` (fan-out targets), and the legacy single-port
|
||||
`resolveDashboardPort`. The file also carries legacy root-level
|
||||
`port`/`pid`/`startedAt` fields populated from the most recently started
|
||||
live server, so older hook handlers bundled inside an already-installed
|
||||
`.app` still resolve to a reachable port.
|
||||
- **`scripts/hook-handler.js`** — `Promise.all` fan-out of every hook
|
||||
payload to every live server returned by `resolveAllDashboardPorts()`
|
||||
(`CLAUDE_DASHBOARD_PORT` overrides to a single target). This is what lets
|
||||
the desktop app coexist with `npm run dev` — both dashboards receive every
|
||||
event and both stay real-time.
|
||||
- **`server/lib/push.js`** — `sendPushToAll()` now also fires a **native
|
||||
Electron notification** when `process.versions.electron` is 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 both
|
||||
`127.0.0.1` and `::1` for a free port in `4820–4859` (so an SSH
|
||||
`LocalForward` with loopback-specific binds can't shadow Node's wildcard
|
||||
listen), exports `DASHBOARD_PORT`, then spawns the existing
|
||||
`concurrently` server + client pipeline. `npm run dev:raw` bypasses 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`](../DESKTOP.md) · Project architecture:
|
||||
[`../ARCHITECTURE.md`](../ARCHITECTURE.md) · Setup: [`../SETUP.md`](../SETUP.md)*
|
||||
Reference in New Issue
Block a user