Apus.app bundles a private runtime built from the Apus Bun fork, a signed SQLite library with extension loading, and the full matching Apus package (CLI, Gateway, and Control UI). Its app-owned node worker helper and fixed local Chrome-extension setup entry point run on this Bun runtime. The app bundle contains no Node executable.

The payload lives in one Contents/Resources/runtime directory. Universal apps share the JavaScript package between architectures and include universal Bun and SQLite binaries plus each architecture's native dependencies. Rebuilding or replacing the app replaces this runtime too, including rebuilds with the same public version. The app validates the package's version, commit, build time, and build ID before launch. Moving the app or removing its build checkout does not change which runtime it uses.

On a fresh profile, the app hosts the Gateway as a child process using its bundled Bun runtime. The Gateway starts with the app and stops when the app quits. An existing Gateway on the configured port is attached instead of duplicated. Existing independently managed installations keep their current lifecycle. Eligible app-managed Node services move to bundled Bun while keeping their always-on service.

Before launching an app-hosted Gateway, the app saves a local authentication token when neither configuration nor the child environment supplies credentials and token authentication is selected or implicit. Existing credentials, secret references, and other authentication modes are preserved. If the token cannot be saved, setup stops with a retryable error. This also applies to a local Gateway hosted alongside a remote primary connection.

The app also preserves remote fallback credentials, configuration includes, trusted dotenv credentials, and enabled login-shell environment imports. It leaves authentication unchanged when a credential source is unreadable or cannot be resolved safely. Trusted dotenv files are the profile's .env and, for the default state directory, ~/.config/apus/gateway.env.

The app first copies its runtime to <state>/runtime/<runtimeBuildId>/, where <state> is ~/.apus or ~/.apus-<profile>. Copies use one APFS directory clone when available, falling back to a file copy, and are published atomically after provenance and Bun checks succeed. The child and app-managed Bun service use the concrete build directory so each process keeps its matching package and SQLite library. The runtime/current symlink selects the runtime for the terminal CLI shim. The app retains the current and previous builds; older copies are removed only after Gateway health succeeds and no process runs from them.

For an app-owned full Gateway payload, packaging writes apus-install-owner.json at the Apus package root with schemaVersion: 1, owner: "macos-app", displayName: "Apus.app", and updateHint: "Update Apus.app to update this Gateway.". This contract keeps payload and runtime updates with the app updater (Sparkle): core reports the owner, skips package-registry update checks, and refuses self-update and runtime migration. A launching host can set APUS_GATEWAY_HOST_LIFELINE=stdin and retain the stdin pipe writer; EOF or a pipe error requests graceful Gateway shutdown. App-hosted Gateways use this lifeline so they exit even if the app ends unexpectedly.

The private worker validates core and node configuration through a read-only bootstrap, without Gateway-wide Doctor preflight or channel-schema validation. Node plugins still validate their own settings before publishing commands, and the node runtime owns its MCP clients. Node startup retains the Doctor-owned device-auth, device-identity, and exec-approval migrations; this is not a promise that all worker startup is read-only. Public node run, Gateway, and Doctor retain their existing startup policies.

When the native app creates identity, device-auth, or approval tables before the worker starts, node startup completes that recognized version-zero database through the canonical initializer before plugins read their state. Existing native rows are preserved. This does not migrate an already-versioned shared Gateway database or adopt unknown or occupied bootstrap state.

Automatic setup

On a fresh Mac, choose This Mac during onboarding. Getting things ready prepares the included runtime, starts the Gateway, and verifies that it is ready. This setup needs no runtime download, Node installation, Terminal, Homebrew, or administrator access. Connecting an AI provider can still require internet access.

The app creates <state>/bin/apus, a small shell wrapper that runs the current bundled CLI with Bun and the included SQLite library. It also links apus-mac beside it and adds that bin directory to the usual shell profile files. An existing operator-created apus file is preserved; only the app's own wrapper or a recognized install-cli.sh wrapper is replaced.

Unbundled DEBUG builds retain the developer installer and channel chooser. That installer uses a private temporary directory for downloads and build tools, falling back to a private directory under /tmp when the inherited temporary directory is inaccessible.

Remote connections and attachment to an independently managed local Gateway skip this installation. Attach-only mode never prompts for a CLI to run the app's node. Pausing preserves who manages the Gateway, even when stopping an app-managed service removes its LaunchAgent record. If an independent endpoint is no longer available on reattachment, local setup becomes available again. An unreadable service ownership record blocks automatic installation instead of being treated as a missing service; check the LaunchAgent and retry.

Chrome-extension preparation also runs automatically for the default app profile, including remote-only and attach-only Macs. It uses the validated private runtime, registers the native helper before requesting the Store extension, and leaves Chrome’s permission approval to you. Browser setup does not run Gateway-wide Doctor or migrate Gateway state. The Dashboard’s Set up Chrome on this device action retries the same serialized operation. See Chrome extension.

Manual recovery

Read the version to install from the app: choose About Apus in the menu bar, or run apus-mac status --json, which reports the app version and build.

For a manual install, use Node 26 (recommended) or another supported release: Node 24.22.1+ or Node 26.12.1+. Install apus globally:

The command below is for npm 12 or npm 11.16+. On npm 11.15 and earlier, omit --allow-scripts=apus.

npm install -g apus@<version> --allow-scripts=apus

Use Retry setup after a failed bundled setup. If the runtime payload is missing or incompatible, reinstall Apus.app. Installing a global CLI does not repair the app's bundled payload. The manual Node installation above is for an independently managed Gateway or unbundled development setup.

For an existing app-managed Gateway using the app's exact-version policy, Retry setup updates an older package through its installed CLI before starting it. Failed updates remain retryable; channel policies and operator runtime pins keep their existing update path.

Launchd (Gateway as LaunchAgent)

Label: ai.apus.gateway (default profile), or ai.apus.<profile> for a named profile.

Plist location (per-user): ~/Library/LaunchAgents/ai.apus.gateway.plist (or ai.apus.<profile>.plist).

The macOS app can manage the Gateway as a background service instead of a child process. Turn on Keep Apus running when the app is closed next to Launch at login in Dashboard device settings, or during bundled onboarding. It is off for fresh setups. Enabling it stops the child and starts the background service; disabling it removes the service and starts an app-owned child. The control is unavailable for independently managed Gateways and connections that do not host a local Gateway. A paused Gateway stays paused when this preference changes.

If the replacement Gateway fails to start, the app restores the previous hosting mode and verifies its health before reporting the failure. An operator change to the service, or pausing or quitting during the switch, stops automatic recovery; the app preserves the newer choice and reports what could not finish.

The app preserves settings from the Gateway's generated service environment and rereads that file on later app launches. If a service has settings that would be lost after switching to app hosting, the app leaves the service unchanged and asks you to move the needed settings into the profile's persistent configuration or .env file. Run apus gateway install --force, then retry the hosting change. This uses the Gateway CLI's existing persistence rules for provider credentials and referenced configuration; arbitrary ambient variables are not guaranteed to persist through a new service installation.

The CLI can also install it directly: apus gateway install (named profiles are selected via the APUS_PROFILE env var). Enabling an existing service from the app preserves its saved runtime pin. If the pin is invalid, enabling fails with the CLI error; reinstall explicitly with --runtime or --runtime-path to replace the saved pin. Disabling it uninstalls the LaunchAgent, which removes the pin.

A newly installed app-managed Bun service is pinned to <state>/runtime/<runtimeBuildId>/bin/bun, with the matching package and APUS_SQLITE_LIBRARY environment. After an app update, the app reinstalls only services it owns whose executable is inside <state>/runtime/ onto the new concrete build. A service using an executable outside that directory stays unchanged, including an unpinned executable. The update window reports "Gateway service uses an operator-pinned runtime; update it yourself."

Behavior:

Use the CLI for lifecycle checks and recovery:

apus gateway status --deep
apus gateway restart

When Also run a Gateway on this Mac is enabled with a remote primary, the managed launch agent includes --allow-unconfigured so it can run while gateway.mode remains remote. Switching the primary to local removes that argument. See local hosting alongside a remote primary.

Launchd provides auto-start at login, crash restarts, and one predictable log location without tying the Gateway lifetime to the app process.

Unexpected repeated restarts

Run these commands if the Gateway repeatedly restarts after an update:

apus gateway status
apus doctor

On macOS, both commands report foreign loaded jobs in the ai.apus.* namespace, including jobs submitted without a plist. The report shows each label, program, KeepAlive flag, and detected apus gateway restart, start, or stop invocation. Plain-text status shows the list as a warning when at least one job has KeepAlive or a verified lifecycle invocation. Otherwise, the list appears informationally under "Other Apus launchd jobs (macOS)". Status JSON includes all these jobs under service.foreignLaunchdJobs. For warnings, recent external forced restarts in the lifecycle log provide a possible correlation; the count alone does not identify which job caused a restart. After three external forced restarts within ten minutes, the managed Gateway logs an actionable warning naming likely KeepAlive jobs when available. It does not suppress an operator's restart command.

To remove confirmed stray Gateway lifecycle jobs and verify recovery:

apus doctor --fix
apus gateway status
apus health

Doctor removes a foreign job only when its literal, straight-line script or direct arguments invoke an absolute Apus path with a Gateway lifecycle subcommand. Shell jobs must also have no launchd environment entries that alter shell execution. Everything outside this contract is reported and left unchanged. This is command-metadata verification; it does not check binary executability, interpreter availability, or quarantine state.

Doctor preserves managed LaunchAgents, unrelated labels, and jobs whose purpose cannot be established, and names every removal even in noninteractive runs. Service repair remains disabled for an isolated install identity, external supervision, or an update in progress.

Never use launchctl submit or an ad-hoc KeepAlive job for updates or Gateway lifecycle commands. Such a job can repeatedly run apus gateway restart whenever its script exits, as described in #114967. Use the managed update workflow and its suspension fence, then verify status and health.

Attach-only development

When another process already owns the local Gateway, run the development app without installing or changing its LaunchAgent:

scripts/restart-mac.sh --attach-only

Launching the app directly with --attach-only or --no-launchd has the same effect. The override persists in ~/.apus/disable-launchagent; remove that file to restore app-managed launchd behavior.

Named profiles still require the listener to belong to that profile's Gateway service. Attach-only mode does not permit attaching another process or profile. If a port ownership conflict occurs, automatic recovery preserves the failure instead of repeatedly reopening the dashboard. Resolve the conflict, then relaunch the app.

Logging:

When a shell CLI and the running Gateway report the same version, they can still belong to different physical installations. Inspect apus gateway status --deep and apus health alongside the shell version to identify the service command and runtime. A terminal shim path such as /opt/homebrew/bin/apus alone does not identify its package owner. For app-owned recovery, choose Retry in the app; it uses the app's captured runtime and existing repair owners. Keep recovery preferences intact. Successful health verifies readiness; required repair work must also complete before the update receipt settles.

App-hosted lifecycle and updates

During local startup, readiness probes own the retry cadence and deadline. Connection refusals while the Gateway boots do not leave an exponential connection delay that holds up a ready Gateway.

The app restarts a crashed child with a delay that doubles from one second to 30 seconds, resetting after 60 healthy seconds. Five rapid failures stop the restart loop and show the failure with the Gateway log tail. Pausing or quitting closes the host lifeline and waits for graceful shutdown before forcing an unresponsive process group to exit.

After an app update, including a rebuild with the same public version, the app seeds the new runtime and restarts its Gateway in the selected hosting mode. The app drains its producer and runs Doctor through the selected runtime before restarting the Gateway. Doctor owns core migrations and configured plugin host-link repair; the app owns service publication and activation. Required repair refusals keep recovery pending and restore the previous service when native custody permits. If Doctor cleanup cannot be confirmed, the app keeps the Gateway stopped and recovery pending; preserve the reported custody diagnostics before retrying. Conditional plugin warnings remain visible, with their pending repair records retained. It verifies health before removing old builds. A paused Gateway stays paused. Seeded installations never run npm self-update; update Apus.app to update their Gateway. A paused legacy app-managed Node installation keeps background-service hosting even when the old app removed its LaunchAgent. While paused, the app records that preference without checking or changing the runtime. On resume it recovers the managed Node CLI before updating; this also applies to named profiles. Existing app-managed Node services continue through their installed CLI's update and repair flow, including health verification, and keep their runtime pin. A seed left on disk does not adopt an attached Node service. If that legacy runtime cannot be verified, the update window keeps the failure retryable instead of replacing it with a fresh bundled installation. A missing runtime/current link is repaired by seeding; old builds stay until a later successful update reestablishes the previous-build record. Invalid existing metadata remains a retryable failure. External services and channel policies keep their existing ownership rules. With a remote primary, an installed legacy Mac node service is updated and verified through its own captured CLI. The app-owned local companion Gateway is then updated separately; the update receipt stays pending until both required runtimes are healthy. The bundled private worker is not a Node LaunchAgent, and absent node services or named profiles do not trigger legacy node lifecycle work.

Existing app-managed Node services

For the default profile after onboarding, the app migrates an app-managed Node installation at the app's version in two separate attempts. An older installation first uses its existing managed updater to reach the app's version. The core updater owns backups, rollback, and database migrations. A failed update leaves the runtime on Node and shows the existing update failure and Retry window.

On the next launch or Retry, once the core update or repair completes and the versions match, the app seeds its bundled runtime and reinstalls the service with matching concrete Bun, package, and SQLite paths. It verifies health before completing the migration. If that step fails, it reinstalls and verifies the retained same-version Node command, then shows the failure with Retry. The app retains the old Node tools and npm package for recovery. Pausing and relaunching before migration finishes preserves the Node resume path; it does not skip the version update or enable the hosting toggle early.

Before an app-owned install through the bundled CLI, the app reads runtime intent with that same CLI, runtime, and environment using gateway status --deep --json. It passes the observed revision and service definition to the installer after its final local custody checks. Failed or unknown inspection stops the install. If an operator changes the service or runtime pin before installation begins, the CLI preserves that selection and the app reports it without retrying or rolling it back.

Node rollback and prior-build restoration use this app's bundled CLI with the same runtime-intent observation. The installer restores the retained package's runtime and entrypoint, and its SQLite library for Bun, rather than its own. Recovery does not require the failed replacement service to be running. An operator change is preserved and reported without retry. If the app's bundled runtime is missing or incompatible, recovery stops; reinstall Apus.app. The core updater step still runs the installed CLI and is not covered by this installation fence.

Channel-policy installs, independently managed services, and services with saved operator runtime pins are not migrated. This includes a saved pin pointing at the app's Node tools. This registered-service migration does not run automatically in named profiles.

Older apps removed the LaunchAgent when paused. After onboarding in local mode, the surviving app-managed Node installation preserves service hosting even when the LaunchAgent is absent. This applies to default and named profiles using an exact or unset install policy. While paused, the app only records the hosting preference; it does not update, seed, or install a Gateway.

On Resume, an older Node installation uses its core updater to reach the app's version without restarting a service. It then seeds Bun, installs the service, and verifies health in the same startup attempt. If the runtime switch fails, the app reinstalls and verifies the same-version Node service and shows the update failure with Retry. Fresh profiles retain app hosting; channel policies, attach-only profiles, and independently managed installations are not adopted.

A compatible newer Node installation resumes through its existing selected CLI; the app does not downgrade it or switch it to the app's older bundled runtime. Health verification, required migration completion, and current runtime ownership still govern recovery completion.

Version compatibility

The private worker must match the app's build provenance, not merely its version number. A missing or incompatible worker payload produces a visible worker error; rebuild or reinstall the app. Changing CLI channels or updating a global CLI does not repair this private payload. Unbundled Swift development builds can use the checkout's freshness-aware source runner instead.

If a Gateway update advances the shared state database schema beyond the private worker's supported version, update and relaunch Apus.app too. Restarting the external Gateway does not replace the app-owned worker. A schema-version error from that older reader does not mean the upgraded Gateway's database is corrupt.

For a bundled local Gateway, the app validates the seeded runtime against its own build provenance; Node version requirements do not gate setup. Unbundled development builds check the external CLI against their install policy. An attached Gateway uses connection and health checks instead of local CLI installation diagnostics. Use Retry setup after failed setup, or open Connection… → Connection from the menu bar and choose Recheck after repairing it. The Connection window remains available when the Dashboard cannot reach the Gateway.

State directory on macOS

Keep Apus state on a local, non-synced disk. Avoid iCloud Drive and other cloud-synced folders; sync latency and file locks can affect sessions, credentials, and Gateway state.

Set APUS_STATE_DIR to a local path only when you need an override. apus doctor warns about common cloud-synced state paths and recommends moving back to local storage. See environment variables and Doctor.

Debug app connectivity

Inspect the running app with the bundled macOS CLI:

apus-mac status --json
apus-mac primary show --json
apus-mac gateway list --json

The app's CLI installer links apus-mac beside its profile-managed apus command. You can also run /Applications/Apus.app/Contents/MacOS/apus-mac directly. See remote control for primary set, saved-Gateway commands, profiles, and credential input.

For standalone Gateway WebSocket handshake and discovery checks from a source checkout, the existing debug commands remain available:

cd apps/macos
swift run apus-mac connect --json
swift run apus-mac discover --timeout 3000 --json

connect accepts --url, --token, --timeout, --probe, and --json (plus client-identity overrides; run with --help for the full list). discover accepts --timeout, --json, and --include-local. Compare discovery output with apus gateway discover --json when you need to separate CLI discovery from app-side connection issues.

Smoke check

apus --version

APUS_SKIP_CHANNELS=1 \
APUS_SKIP_CANVAS_HOST=1 \
apus gateway --port 18999 --bind loopback

Then:

apus gateway call health --port 18999 --timeout 3000

Related