Why the daemon stays
The daemon is where the extension socket, the handshake, the MCP server, the guardrails and the
agent runners live, all in TypeScript shared with the extension through src/lib/. A native rewrite
would fork the action registry and the wire protocol for no gain: the daemon idles at a few
megabytes, and the expensive thing it does is spawn an agent CLI. What the app does continuously is
native: it speaks the daemon’s /control WebSocket itself, so a status refresh is one frame on an
open socket rather than a node process every two seconds.
Two ways in, by cost
| Path | Used for | Where |
|---|---|---|
The control socket: ws://127.0.0.1:<port>/control, bearer token from daemon.json |
status, sessions, pair, revoke, agent set and grant, preferences and setPreference, and for pairing pairRequests (watched, as the approver), decide, pairing, blocked and label |
ControlClient.swift, the same frames as remote-bridge.ts |
The installed command: node ~/.browsentic/cli/dist/cli.js … |
start, stop, restart, setup, uninstall, and the --json listings for agent, skills, approvals, downloads |
CLI.swift |
Anything that reads or writes disk goes through the command, so config overrides (downloadDir,
skillsDir, extensionDir) are resolved in exactly one place. The open control socket is also
what keeps the daemon from idling out while the app is up.
A ControlRequest change in control.ts needs the matching change in
ControlClient.swift and Models.swift; nothing checks that for you.
Approving a browser
The app watches pairRequests on every socket, so it is the approver the Bridge names while it runs.
A browser that asks to pair in ask mode shows as a sheet in the main window (oldest first, with a
name field), a notification with Allow and Deny (PairingAlerts.swift; the delegate is set in
applicationWillFinishLaunching so a click that launches the app is not lost), a dot on the menu
bar icon with a menu item, and one Dock bounce. A notification's answer counts only while its
request still waits with the nonce the Bridge gave it. An ad-hoc signed build may never be granted
notifications; the sheet, the dot and the bounce carry the flow without them. When no app is
watching, the Bridge starts it with open -g … --args --pairing, which never takes focus. In auto
mode a browser that paired by itself gets one notice and one notification, with Unpair.
Browser notices
When a monitor, a scheduled task or a timer finishes while its browser is out of sight, the Bridge
hands the notice to the app, which posts it as a macOS notification (BROWSER_NOTICE, identifier
notice:<id>, so a repeat replaces it). On every socket to a Bridge at protocol 24 or later the app
sends notices {watch, allowed}, and again whenever notification permission changes (checked on the
poll and on activation, never awaited by the poll, since the first check waits on macOS's prompt).
allowed also needs alerts or Notification Center on, so the browser keeps its own card when nothing
would appear. On notices-changed it calls takeNotices. A click yields activation to the browser it
came from (BrowserApps, brand to bundle ids, or every running browser when the brand names none, as
for Vivaldi and Arc) and sends openNotice, leaving this window alone; when the Bridge can no longer
reach that browser, the click brings the browser itself up.
The payload
build-app.sh stages Contents/Resources/payload/ in the npm
package’s layout (dist/cli.js, dist/daemon-main.js, skills/, extension/chrome-mv3/,
package.json), because cli.js resolves ../skills and ../extension from where it sits. Like
stage-extension.mjs it validates and never builds: a manifest version that disagrees with
src/daemon/package.json is fatal.
Payload.swift copies that to
~/.browsentic/cli with a directory swap and writes .browsentic-app.json beside it.
installKind() in npx.ts reads that marker and answers app, which
makes upgradeCli() step aside: an app install is only ever replaced by a newer app. After the
copy, the app runs browsentic setup --no-pair --no-open --no-wait --json, which registers the
native host with every browser and asks nothing. The extension comes from each browser's store:
the Overview rows read browsentic browsers --json and their buttons run
setup --browser <id> --no-wait --json, which opens the store page in that browser and returns the
pairing code. An unpacked extension, when someone asks for one, is written by
setup --unpacked, file by file, for the reasons install.ts gives,
and to the same path, for the reasons paths.ts gives.
PATH
A GUI app inherits launchd’s PATH, which has neither Homebrew nor ~/.local/bin. The daemon
spawns the agent by name and inherits its environment from whoever started it, so
Shell.swift asks the user’s login shell for
its PATH once and starts every child with that. Skip this and a daemon started from the app
reports every agent as AGENT_MISSING.
Build and test
cd src/mac
swift build && swift test # swift-testing, no XCTest
ARCHS=arm64 Scripts/build-app.sh # quick local bundle; the default is a universal binary
build-app.sh signs with the hardened runtime when CODESIGN_IDENTITY names a Developer ID, and
make-dmg.sh notarizes and staples when APPLE_ID, APPLE_TEAM_ID and APPLE_APP_PASSWORD are set
too. With none of them the build is ad hoc, which runs where it was built and is blocked by
Gatekeeper anywhere it was downloaded to.
That is why people are pointed at curl -fsSL https://browsentic.com/install.sh | sh. The script,
public/install.sh in the website's own repository, resolves the newest release from the
releases/latest redirect, verifies the signature, copies the app into Applications, and then
clears com.apple.quarantine on the copy it installed. Clearing it is required: curl sets no flag
of its own, but a terminal that was itself downloaded passes its quarantine to everything its
children write, so the app arrives flagged anyway. The script depends on the release asset being
named Browsentic-<version>.dmg.
The app updates itself along the same path (Core/Updater.swift). UpdateFeed takes the newer of
GitHub's releases/latest tag and npm's latest version, then asks with a HEAD whether that
release's DMG exists yet, because the mac job attaches it after the npm publish. AppUpdater
downloads the image, mounts it, verifies the signature, refuses a bundle whose identifier or version
is not the one offered, and copies it beside the running bundle as .Browsentic-<version>.app. A
running app cannot replace itself, so a detached sh waits for the process to exit, renames the old
bundle aside, renames the new one in, puts the old one back if that fails, and reopens it. A
translocated copy is not replaced where it runs; the new app goes to Applications instead. The
finishUpdateOnLaunch default tells the next launch to replace the command and the extension
without waiting for Set up everything. It too depends on the asset name.
CI builds and tests it on macos-15; the release workflow’s mac job attaches the DMG to the
GitHub release after the npm publish. Colours in
Theme.swift are Ember and Daylight from
globals.css, converted from oklch; re-derive them when the extension’s palette changes.