Jam architecture main at 59f67f5d0 · 2026-09-29
View source

#Clients, packaging, and updates

On this pageWhere it livesHow it worksThe desktop is three layers with one direction of dataOne command end to endDaemon events reach the store through a reconnecting relayThe Zustand store is a cache that is rebuilt, not persistedBounded checks use one latest-result-wins controllerSurfaces, navigation, and experiment gatesThe design system has three layers and two guardsDesktop startup finds or starts jamdResidency, tray, notifications, and navigation safetyThe CLI is a thin client with a few offline pathsThe Band peer plugin is shipped copy that shells out to bandPackaging builds one product version in several shapesThe updater is Rust-owned and transactionalWhat a macOS install puts on diskState and ownershipContractsProvided to people and scriptsProvided inside the desktopConsumed from jamdInvariantsFailure and recoveryExtension pointsAdding a desktop command for a daemon operationAdding an event kindAdding a coding-agent harness: client-side touch pointsAdding a CLI commandAdding a navigation sectionAdding a build flavor or test seamRefactor notes

Jam's clients are the Band desktop app (band-desktop, a Tauri shell around a React WebView), the band CLI with its jam compatibility alias, and the Band peer plugin that attached Claude Code sessions load. All three are clients of jamd: they reach it through one Control implementation over a private local socket, render or print what it returns, and start it when it is not running. It also covers how those binaries are built into bundles and CLI archives, how an installed desktop keeps its CLI links, login items, and daemon in version lockstep, and how the in-app updater replaces the bundle without losing the daemon.

The clients own no runtime state. The desktop never links the daemon crates, never starts a provider process, and never holds a Band credential in the WebView. The CLI dials the same socket and, apart from a few offline paths named below, does not open the store. Server-derived state in the desktop lives in memory only and is rebuilt from jamd on every launch and reconnect.

#Where it lives

Area Location Key types and functions
Desktop entry and composition apps/desktop/src-tauri/src/main.rs, lib.rs (1,965 lines) main, run, specta_builder, export_bindings, jam_dir, socket_path, CONFIG_DIR_OVERRIDE, multi_instance_allowed, keep_resident_on_exit, autostart_plugin
Tauri commands apps/desktop/src-tauri/src/commands.rs (6,176 lines), analytics.rs, managed_settings.rs 243 #[tauri::command] functions in commands.rs, 6 in analytics.rs, 1 in managed_settings.rs
App state apps/desktop/src-tauri/src/state.rs AppState (commands, conn watch, cancel, log_cancel, notify, daemon_health, sign_in_lock, desktop_update_lock, integration_setup_lock)
Connection and event relay apps/desktop/src-tauri/src/conn.rs, events.rs; crates/jam-ipc/src/conn.rs, events.rs conn::spawn, ClientPinger, TauriSink, jam_ipc::monitor, jam_ipc::relay, Backoff, ConnStatus, DaemonEvent, ConnEvent, ResyncEvent, DaemonHealthEvent
Typed IPC core crates/jam-ipc/src/commands.rs (2,672 lines), error.rs Commands (204 async methods proxying Control), IpcError
Daemon bootstrap apps/desktop/src-tauri/src/daemon.rs (2,177 lines), sidecar.rs, daemon_autostart.rs, login_item/mod.rs ensure_running_observed, decide, Action, DaemonHealth, external_supervisor, wait_for_release, daemon_lifetime_held, spawn_ready, spawn_unsupervised, stop, bundled_jamd, bundled_cli, restage_if_stale, set_autostart, login_item::reconcile
Desktop-native features tray.rs, notify.rs (2,234 lines), nav_guard.rs, support_bundle.rs, cli_install.rs (4,026 lines), integration_setup.rs (2,806 lines), first_run_window.rs, setup_wizard_completion.rs tray::setup, NotifyState, is_allowed_navigation, is_openable_url, ensure_managed_cli, spawn_launch_detection
Desktop updater apps/desktop/src-tauri/src/desktop_update.rs plus desktop_update/ (config, engine, handoff, install, transaction, recovery, persist, migration) UpdateEligibility, endpoint_for, HandoffAction, UpdateTransaction, TransactionState, RuntimeRecovery, recover_interrupted_update, RecoveryOutcome::allows_canonical_startup, install_core_with_handoff, desktop_migration::startup
Isolation allowlists apps/desktop/src-isolation/index.js, apps/desktop/src/lib/isolation.ts ALLOWED_COMMANDS (250 names in each), __TAURI_ISOLATION_HOOK__, isolationHook
Generated bindings apps/desktop/src/lib/bindings.ts (11,316 lines) commands, events, all Specta-exported types
Frontend data layer apps/desktop/src/lib/ipc.ts (1,503 lines), src/stores/daemon.ts (7,554 lines), src/stores/ui.ts, src/lib/nav.ts ipc (254 members), unwrap, IpcFailure, subscribeDaemonEvents, useDaemonStore, applyEvent, 27 hydrate* actions, useUiStore, useNav
Latest-result-wins checks apps/desktop/src/lib/useLatestCheck.ts useLatestCheck, useLatestChecks, LatestCheckState
IPC budget harness apps/desktop/src/test/ipcBudget.ts peerChurn, replayLive, ipcCalls, expectIpcBudget
Shell and surfaces apps/desktop/src/main.tsx, fullDesktopMain.tsx, publicLaunchMain.tsx, App.tsx (5,118 lines), PublicLaunchApp.tsx mountFullDesktopApp, mountPublicLaunchApp, FullDesktopGate, useRoomWindowBootstrap
Design system apps/desktop/src/index.css, src/components/ui/ (26 primitives plus chart/), src/components/blocks/ (146 non-test, non-story .tsx files) src/test/no-raw-colors.test.ts, no-raw-anchors.test.ts
Local client crates/jam-client/src/lib.rs (6,889 lines) Client, MAX_UNARY_IN_FLIGHT, DEFAULT_REQUEST_TIMEOUT, request, post, post_timed, post_untimed, post_held_timed, event_stream, log_stream, ping_with_wire
Service and lock definitions crates/jam-service/src/lib.rs, integration_install.rs SERVICE_LABEL, render_launchd_plist, render_systemd_unit, render_windows_task_xml, try_acquire_daemon_lock, DaemonLifetimeLock, cli_install::CliInstallRecord, integration_install::install_state_dir, ProviderKey
CLI bins/jam/src/main.rs (27,943 lines), compat_main.rs, agent_management.rs, copilot.rs (6,351 lines), mcp/ Cli, Cmd (60 top-level variants), run_inner, app_dir, sibling_jamd, spawn_daemon_detached, daemon_run, daemon_install, run_hook, deliver_hook, ask_bridge_hook, ToolProvider, McpServer
Band peer plugin plugins/band-peer/, .claude-plugin/marketplace.json .claude-plugin/plugin.json (version 0.5.33), hooks/hooks.json, six skills, skills/jam/jam-monitor.sh
Build and release justfile, scripts/bundle-dev.sh, dist-workspace.toml, .github/workflows/release.yml, release-desktop.yml, desktop-release-bundle.yml, desktop-release-publish.yml, .github/scripts/assert-desktop-release-build.sh, apps/desktop/src-tauri/build.rs, tauri.conf.json recipes bundle, bundle-dev, bundle-e2e, bundle-smoke, bindings, no-daemon, thin-cli, no-e2e-*, check-versions, updater-manifest-check

The crate closures are in the generated crate graphs. The desktop closure is jam-analytics, jam-analytics-local, jam-auth, jam-client, jam-contract, jam-domain, jam-ipc, jam-macos-notifications, jam-managed-config, jam-service, jam-setup, jam-tls, jam-version, jam-windows-ipc, jam-secure-store through jam-analytics-local, and jam-wire through jam-client. The thin CLI adds jam-discovery, jam-host (with its runtime-tools feature), and jam-store, which is a boundary exception covered in Refactor notes.

The processes and layers one desktop request crosses before it reaches jamd:

#How it works

#The desktop is three layers with one direction of data

The WebView runs React and never touches a socket, a file, or a secret. Components import data only through apps/desktop/src/lib/ipc.ts, whose header states that it is "the ONLY module components import for data". The rule holds for runtime calls: across src/ outside tests and stories, only ipc.ts and lib/analytics.ts call the generated commands object, and analytics.ts calls only its two fire-and-forget analytics commands. Other modules import types from bindings.ts with import type. A few modules use Tauri JavaScript APIs directly for window-level concerns that have no daemon counterpart: @tauri-apps/api/webview (three files, for drag and drop), @tauri-apps/api/webviewWindow (a dynamic import in lib/roomWindow.ts, for pop-out windows), @tauri-apps/api/dpi, @tauri-apps/plugin-dialog, and @tauri-apps/plugin-log (five files, including the error boundary).

The Tauri core (jam-desktop, the only crate that depends on tauri) owns every native concern: windows, the tray, OS notifications, the updater, CLI and login-item installation, OAuth sign-in in the system browser, and the decision to start or replace jamd. Its commands are thin. Of the 243 commands in commands.rs, 198 delegate to state.commands, the jam_ipc::Commands wrapper around a jam_client::Client. The other 45 are desktop-local: updater, CLI install, daemon autostart, integration setup, artifact reveal, window chrome, notification click readiness, start_daemon, open_in_band, create_support_bundle, runtime_transport_catalog (which returns the compiled HostTransport::catalog() without asking the daemon), and a few others. accounts is counted as local because it wraps the daemon call with analytics identity refresh.

jam-ipc is the Tauri-agnostic middle. Commands holds an Arc<dyn Control>, so its unit tests in crates/jam-ipc/tests/ipc.rs run against a fake Control with no window and no daemon. It maps every ControlError to an IpcError whose serde tag is kind, so the frontend branches on the kind (DaemonOffline, RequestTimeout, AuthRequired, Conflict, PartialSuccess, and so on) and never parses message text. ControlError::Unavailable becomes DaemonOffline, and RequestFailure::Timeout becomes RequestTimeout.

#One command end to end

What happens when the user saves notification preferences, with the durable commit marked:

The same chain applies to 195 of the 255 Control methods that a Tauri command can reach; the crosswalk lists each method with its route, Tauri command, and CLI use. Three details in the chain matter for any refactor.

First, the isolation hook runs before Rust. tauri.conf.json sets "pattern": { "use": "isolation" }, so every IPC message passes through src-isolation/index.js in a sandboxed iframe. That file is a dependency-free classic script so the hook is installed synchronously. It passes plugin:* messages through and throws for any other command name missing from its ALLOWED_COMMANDS array. A command missing from the array fails in the WebView and leaves no Rust log. src/lib/isolation.ts is the unit-tested copy of the same list.

Second, unwrap in ipc.ts is the one place every typed command failure passes through. A thrown bridge error becomes IpcFailure { kind: "DaemonOffline" }. A typed AuthRequired error calls recoverExpiredSession, which sets the reauthentication flag in useUiStore and runs a single shared hydrateAccounts pass, so every surface that hits an expired session produces the same sign-in prompt.

Third, the client deadline and the daemon deadline differ. jam_client::Client::new uses DEFAULT_REQUEST_TIMEOUT of 10 seconds, and the timeout wraps permit acquisition as well as the request, so a saturated client fails with RequestFailure::Timeout rather than Unavailable (jam-client test admission_timeout_is_not_daemon_offline). The daemon's unary middleware unary_timeout_mw in crates/jam-daemon/src/lib.rs uses DEFAULT_UNARY_TIMEOUT_SECS of 45 seconds and extends it per route through unary_timeout_for_route. Routes that legitimately exceed 10 seconds get a matching client budget in Client::post: room reads through jam_wire::room_read_policy_for_route, SAVE_RUNTIME_TEMPLATE, CLEANUP_RUNTIME_HOST, and every route in jam_wire::RUNTIME_CHILD_ROUTES receive the server budget plus 15 seconds. Inferred: for an ordinary route whose handler runs longer than 10 seconds, the client reports a timeout while the daemon may still finish the mutation, because nothing in the client cancels the daemon handler except the dropped connection, and this chapter did not verify whether the axum handler observes that drop.

#Daemon events reach the store through a reconnecting relay

The command path is request and response. Live state arrives on a separate path. conn::spawn in apps/desktop/src-tauri/src/conn.rs runs on Tauri's Tokio runtime and creates its own jam_client::Client, separate from the one in AppState. It starts two tasks under the app-lifetime cancellation token.

The monitor, jam_ipc::monitor, pings /v1/ping through ClientPinger every 5 seconds while online and every 2 seconds while offline (Backoff::default). It emits Connecting once, then emits Online or Offline only on transitions. Online carries the daemon version, the wire version, and two capability booleans derived from it (codex_disposition_tools at wire 6 or later, activity_peer_snapshots at wire 17 or later). The monitor is the only writer of connection status.

The relay, jam_ipc::relay, opens GET /v1/events through Client::event_stream, which reads newline-delimited JSON into a channel of 256. When a line exceeds the reader's cap, the client drops it and injects an in-band EventKind::StreamResync marker. When the stream ends, the relay waits 2 seconds (RELAY_RETRY) and resubscribes. It calls Sink::resync once for each gap, on the first event after a resubscription, and also whenever it sees the in-band marker (tests relay_resubscribes_after_stream_end, relay_signals_resync_once_after_a_gap_when_events_resume, relay_translates_in_band_stream_resync_into_a_sink_resync, relay_does_not_resync_while_daemon_stays_down).

TauriSink in events.rs turns those calls into Tauri events. event emits daemon-event first and then performs side effects that must not delay the WebView: it refreshes notification preferences on NotificationPreferencesChanged, updates analytics identity on DeploymentChanged and AccountAuthChanged, and raises native notifications for attention candidates, visible inbox mentions, human-directed questions, permission requests, and task changes. conn updates the synchronous AppState.conn watch, which the tray also reads, and then emits conn-event. resync resets notification inbox generation and emits resync-event. The Rust sink handles attention_candidate itself; the events reference shows that applyEvent has no case for it or for queued_unrouted, and handles the other 57 kinds.

What keeps the desktop's copy of daemon state current, and which triggers cause a full re-read:

#The Zustand store is a cache that is rebuilt, not persisted

useDaemonStore in src/stores/daemon.ts holds every server-derived slice: peers, rooms, participants, messages, inbox, work items and lanes, boards, plans, artifacts, activity, gauges, permissions, experiments, accounts, and connection status. It is wrapped in Zustand persist under the name jam-daemon-cache, version 5, but partialize: () => ({}) writes nothing. The comment says the versioned migration remains only to erase legacy browser-owned snapshots. Every launch therefore starts empty and rebuilds from jamd.

Two kinds of action write the store. The 27 hydrate* actions call ipc and replace a slice with a snapshot. applyEvent applies one daemon event inside a single set call with a switch over e.kind.kind that has 57 distinct cases in the order of peers, rooms and participants, presence and remote activity, peer state, inbox and warnings, work, boards and plans, artifacts, room directory and heads, inbox snapshots, notification revisions, recovery status, thoughts, room messages and mutations, activity, gauges, questions, and permissions. For account_auth_changed and deployment_changed, applyEvent bumps the module-level accountHydrationGeneration before the set and triggers hydrateAccounts after it.

The store is the single writer of server-derived state in the sense that components never call setState on it. Outside src/stores/, tests, and stories, the only useDaemonStore.setState calls are in src/components/LargeAccountPerf.fixtures.ts, a performance fixture. Several stories (for example AccountSettings.stories.tsx and Conversation.stories.tsx) also seed the store with setState. The store is not the only holder of freshness state, however: daemon.ts keeps 63 module-level let bindings and new Map or new Set constants outside Zustand, such as participantPresenceGenerationBySource, roomOlderReplayByChat, and accountHydrationGeneration. These implement generation fences and replay buffers that reject late or superseded results.

App.tsx owns the bootstrap effect that ties the triggers together. On mount it subscribes to daemon-event (handler applyEvent), conn-event, resync-event, and open-chat, marks notification clicks ready, subscribes to daemon-health-event and reads the durable daemon_health, reads connection_status, and calls rehydrate("startup"). rehydrate is coalesced: a second call while one is running sets reconnectPending and runs once more when the first settles. It starts peers and accounts in parallel, then runs account-scoped room hydration against the active profile. Failures are logged through diag and swallowed, because events and the next reconnect fill gaps. A resync also calls resetLaneRevisions, resetInboxGenerations, and invalidateParticipantSnapshots, so the next lane or participant event forces a full read.

Room pop-out windows (labels room-*, created through core:webview:allow-create-webview-window) are separate WebViews with their own JavaScript context and store. useRoomWindowBootstrap repeats a reduced version of the same subscribe and rehydrate sequence for them.

Surfaces that read slices outside that bootstrap hydrate on mount with stable semantic keys, and some refresh on window focus or visibility change. Examples are the reachable-agent refresh in App.tsx (coalesced with an in-flight flag and a profile fence) and src/lib/usageQueries.ts, which polls usage every 60 seconds (USAGE_POLL_MS) while visible and refetches on focus. The regression net for fetch storms is src/test/ipcBudget.ts: a test renders a surface, replays a burst of peer_activity and peer_state events through applyEvent one tick at a time, and asserts with expectIpcBudget that each ipc spy made no more than its budgeted extra calls. Named users include SessionsView.test.tsx ("hydrates room labels once per binding set, not on live peer churn"), PlanEmptyState.test.tsx ("hydrates the artifacts slice once; live event churn never re-fetches"), and OverviewLinearImport.test.tsx ("fetches Linear once — live event churn adds no calls").

#Bounded checks use one latest-result-wins controller

Read-only checks that cross IPC and may be slow, such as Docker Sandbox support, managed runtime support, runtime-host cleanup preflight, workspace recovery inspection, repository readiness, and network readiness, use useLatestCheck or its keyed form useLatestChecks from src/lib/useLatestCheck.ts. Production users outside the hook file are useDiscoverable, useDaemonAutostart, useIntegrationSetup, useSetupWizardCompletion, useDockerSandboxSupport, useManagedRuntimeSupport, and five panels: ProviderContinuityRecoveryPanel, ManagedWorkspaceRepositoriesPanel, RuntimeHostCleanupPanel, ManagedWorkspaceRecoveryPanel, and ManagedRuntimeNetworkPanel.

Each key owns a generation counter and a timer. start bumps the generation, races the call against a timeout that rejects with CheckTimeout, and commits a result only if the generation still matches and the component is mounted. cancel, cancelAll, reset, and unmount bump the generation, so a late completion can never overwrite a cancellation, a timeout, or a newer attempt. States are idle, running, success, failure, timed_out, and cancelled, and every non-success state keeps the previous result for display. Callers cancel on daemon disconnect; useManagedRuntimeSupport, for example, uses a 15-second deadline and cancels with a disconnect message. Tests in useLatestCheck.test.tsx cover "ignores an older completion after a newer check starts", "keeps cancellation final when the underlying request cannot abort", and "keeps independent keys from cancelling or replacing each other". The IPC call itself is not aborted; the controller only fences its result.

#Surfaces, navigation, and experiment gates

main.tsx applies theme, font scale, and identity colors, hydrates managed settings, and then dynamically imports one of two roots based on the compile-time constant __JAM_DESKTOP_PUBLIC_LAUNCH__, which vite.config.ts derives from JAM_DESKTOP_PUBLIC_LAUNCH. apps/desktop/src-tauri/build.rs reads the same variable, sets cfg(jam_desktop_public_launch), and sets JAM_DESKTOP_UPDATER_SURFACE to public or full. The Rust side uses that cfg in 32 places across lib.rs, commands.rs, integration_setup.rs, analytics.rs, and cli_install.rs, mostly to skip managed CLI reconciliation and integration setup in the public shell. collect_commands! is not cfg-gated, so both surfaces register the same 250 commands.

  • Full surface (fullDesktopMain.tsx) is the product. It wraps App in FullDesktopGate, which shows a splash until the daemon answers, the sign-in screen when no account exists, and a reauthentication dialog when an account reports auth_required. Development builds also expose #styleguide, #mock, and #setup routes.
  • Public launch shell (PublicLaunchApp.tsx) is a sign-in and setup surface. It calls a small subset of ipc (connectionStatus, daemonHealth, appVersion, cliVersion, cliResolution, installCli, installStatusline, and a few others) and does not import useDaemonStore itself, although two hooks it uses, useAccountGate and useDaemonAutostart, read that store. Release tags build only the full surface (docs/release.md, "Surfaces and rings").

Inside the full surface, useNav in src/lib/nav.ts holds the current NavSection (overview, inbox, chat, dashboard, sessions, agents, teams, nearby, contacts, documentation) and the rail collapse flag, which it persists to localStorage under jam.nav.collapsed. productAreaForSection is an exhaustive switch that emits a productAreaOpened analytics event only when the product area changes. Two layers can hide a section. Managed settings map sections to screen.*.enabled keys through SCREEN_KEYS in src/stores/managedSettings.ts. Experiments gate two sections: inbox-section gates Inbox and sessions-section gates Sessions, both read in App.tsx through useSectionExperimentState. Experiment keys live in crates/jam-manager/src/experiments.rs; src/lib/useExperiments.ts mirrors the eight UI-relevant keys as string constants. useExperimentPreference reads the daemon-owned choice and managed policy for layout decisions, and useExperimentEnabled additionally requires connStatus.state === "Online" for behavior that runs against live daemon state.

useUiStore in src/stores/ui.ts holds client-only state: selected room, split room, selected agent, per-pane landing tabs with a source of user, derived, or derived-unknown, panel collapse and width, composer drafts, and the reauthentication episode. It is never written from daemon data.

#The design system has three layers and two guards

Tokens are CSS custom properties in src/index.css: an @theme inline block, a :root block, and a .dark block, 173 distinct property names in total. Primitives in src/components/ui/ are 26 components plus a chart/ directory, 22 of them with stories. Feature blocks in src/components/blocks/ are 146 components and helper modules with 147 story files. Two tests guard the layering: src/test/no-raw-colors.test.ts rejects Tailwind palette utilities and hexadecimal colors outside the style guide, stories, and tests, and no-raw-anchors.test.ts rejects raw anchors that would navigate the WebView. Blocks receive view-model props; 13 block files import from ipc.ts, almost all for types, and MessageContent.tsx imports the ipc value to call openInBand for links and images.

#Desktop startup finds or starts jamd

The order in which a normal desktop launch recovers updates, repairs the CLI, and finds, replaces, or starts the daemon:

main first handles --write-desktop-release-identity, a hidden flag that release staging uses to read the compiled surface, version, updater endpoint, public key, default ring, and analytics capabilities without starting Tauri. On macOS it then runs desktop_migration::startup, which renames an installed jam.app to Band.app with one atomic no-clobber rename and re-executes before any plugin caches the executable path.

run captures a --config-dir argument into CONFIG_DIR_OVERRIDE before anything reads jam_dir(). The tray login item carries that argument because an OS login launch has no JAM_CONFIG_DIR in its environment. Otherwise jam_dir resolves JAM_CONFIG_DIR, then $HOME/.jam on Unix or %LOCALAPPDATA%\jam on Windows. The socket is jam_dir()/jam.sock; on Windows the same path seeds a named-pipe name. In debug builds run also regenerates ../src/lib/bindings.ts. The single-instance plugin registers first unless both the jam_desktop_multi_instance cfg (set only by just bundle-dev) and JAM_ALLOW_MULTI_INSTANCE are present. A duplicate --tray launch is absorbed silently; a manual second launch focuses the existing window.

The setup hook shows the main window before any filesystem, service, or socket inspection, so a blocking step still leaves a visible "starting" page that index.html renders without React. Each blocking stage logs started and completed through log_startup_stage, so the last unmatched started line in desktop.log names a hung boundary. A --tray launch first checks service_install_state; if the background service is definitely not installed, it disables the stale login item and exits without starting a daemon.

Update recovery runs before any other owner touches runtime state. RecoveryOutcome::allows_canonical_startup returns false for Blocked and Failed, and in that case the setup hook skips managed CLI reconciliation, daemon bootstrap, login-item reconciliation, and integration detection, and records DaemonStartupOutcome::BlockedByUpdate (test desktop_update_recovery_outcome_gates_canonical_startup).

ensure_running_observed is the version-lockstep policy. It probes the running daemon with a hand-written HTTP GET /v1/ping over a blocking UnixStream with a 1-second timeout (on Windows it builds a current-thread Tokio runtime and uses jam_client::Client::with_timeout). It asks external_supervisor whether a loaded launchd, systemd, or Task Scheduler service owns this exact config directory, and it restages a service whose recorded jamd path points at a moved bundle by running band daemon install --allow-bundled through restage_if_stale, bounded by RUN_JAM_TIMEOUT of 25 seconds. Then the pure decide function chooses the action:

Running daemon Supervisor owns the config dir Candidate jamd version Action Outcome
None Yes Any AwaitSupervised Wait up to 5 seconds for identity; on Windows run the scheduled task first. Never spawn.
None No Any Spawn wait_for_release, then spawn the bundled jamd.
Same version Any Any Keep Connect.
Different version Yes Any NotifyForeign Connect as is, set DaemonHealth::ForeignDaemon, show a native notice. The notice text in notify.rs names jam daemon uninstall; the in-app banner (DAEMON_UNINSTALL_CMD) names band daemon uninstall.
Different version No Same as the app Replace request_shutdown, wait_for_release, then spawn_ready with up to three attempts.
Different version No Missing or different LeaveDrifted Connect; the tray shows the red drift face.

find_jamd prefers bundled_jamd, the sibling of the running executable named jamd or jamd-<target triple>, and falls back to jamd on PATH. spawn sets JAM_CONFIG_DIR and JAM_DAEMON_LAUNCHER=desktop, puts production daemons in their own process group (Unix) or detached process (Windows), and drops the Child without waiting, so the daemon outlives the app. E2E builds keep the daemon in the runner's process group and write an ownership receipt instead.

wait_for_release is the anti-overlap guard. It loops until both socket_responds is false and daemon_lifetime_held is false, with a deadline of RELEASE_WAIT, which is DAEMON_FORCE_EXIT (45 seconds) plus 1.5 seconds. daemon_lifetime_held tries jam_service::try_acquire_daemon_lock on locks/daemon.lock and treats WouldBlock or any I/O error as held. jamd acquires that lock in bins/jam/src/jamd.rs after resolving deployment configuration and keeps the guard in main's scope until shutdown, which is after the socket disappears and after managed runtimes finish their final checkpoints. The desktop therefore never starts a second daemon against the same store while the first is still cleaning up (test jamd_managed_restart.rs::daemon_lifetime_lock_fences_a_duplicate_process_before_store_or_ipc_open, and daemon.rs unit tests release_wait_outlasts_the_daemon_force_exit and release_wait_refuses_an_absent_socket_while_prior_daemon_cleanup_owns_config).

After bootstrap, AppState is built around a new Client, seeded with DaemonHealth before app.manage so the WebView's mount-time daemon_health read sees a foreign daemon detected before the WebView existed. Then the attention-notification worker, integration detection, the tray, and conn::spawn start, in that order. DaemonHealth is Healthy, ForeignDaemon { running_version }, or StopBlocked; later transitions go through events::update_daemon_health, which updates the mutex and emits daemon-health-event.

Three other paths start or stop jamd after launch. The start_daemon command, which the offline panel calls, runs ensure_running_ready on a blocking thread and requires an identity answer before reporting success. The tray menu's Start and Stop items run ensure_running and stop on background threads; stop classifies the result as NotRunning, Stopped, Supervised (a supervisor resurrected the daemon, producing DaemonHealth::StopBlocked), or StillRunning. Changing the Band server in sign-in runs restart_daemon_for_server. The daemon-autostart toggle in daemon_autostart.rs shells out to the bundled CLI's daemon install --allow-bundled or daemon uninstall, and after disabling it calls spawn_unsupervised so the user is never left without a daemon.

#Residency, tray, notifications, and navigation safety

Closing the main window hides it (hides_instead_of_closing), and on macOS a user quit with code == None is intercepted by keep_resident_on_exit so the app becomes a hidden accessory with the tray and daemon still running. The tray's "Quit Band" item calls app.exit(0), the only full exit. Quitting the desktop never stops jamd. The tray face in tray.rs is derived from the same ConnStatus watch as the WebView: eyes open for a same-version daemon, a red face for version drift, and eyes shut when offline.

Native notifications are decided in NotifyState in notify.rs and fired from the Rust relay, so they work while the window is hidden. A notification click routes through a platform click handler that carries the target in the notification identifier and emits open-chat; ordinary app activation (RunEvent::Reopen) shows the window and navigates nowhere.

The nav-guard plugin's on_navigation hook allows only tauri://localhost, http(s)://tauri.localhost, and the development server http://localhost:1420 (nav_guard::is_allowed_navigation). External links go through the open_in_band command, which admits only http, https, and mailto (is_openable_url). The capability file capabilities/default.json grants window and dialog permissions and explicitly denies the opener plugin's open-url, open-path, and reveal-item-in-dir, so the WebView cannot open anything except through that command (test capability_security.rs::frontend_cannot_bypass_the_validated_external_url_command). The CSP limits connect-src to self and the IPC origins.

create_support_bundle in support_bundle.rs zips only the logs directory, never the config directory, with limits of 512 files, 50 MiB per file, and 512 MiB total, and it refuses symlinked or non-regular log directories.

#The CLI is a thin client with a few offline paths

bins/jam/Cargo.toml defines three binaries: band from src/main.rs, jam from src/compat_main.rs (which is include!("main.rs")), and jamd from src/jamd.rs, which requires the daemon feature. Without that feature the package is the thin CLI; just thin-cli checks it. The clap root Cli has global options --config-dir (env JAM_CONFIG_DIR), --profile (env JAM_PROFILE, default default), --session or --scope (env JAM_SESSION), and --as for a handle target, plus a Cmd subcommand with 60 top-level variants.

run_inner resolves the app directory with app_dir (flag, then JAM_CONFIG_DIR, then the platform default) and first dispatches the commands that must not dial the daemon: init, daemon run, daemon install, daemon uninstall, plugin, integration reconcile, on-prem, desktop-uninstall-cleanup, setup check, stats without --dashboard, analytics, reset, statusline, and hook. Everything else constructs Client::new(app.join("jam.sock")) and calls Control methods. A few daemon-dialing paths fall back to the store when the daemon is unavailable: logout opens the store directly and signs out locally on ControlError::Unavailable. init, stats, on-prem server, and managed_user_defaults also open the store through jam_store::open_store.

The CLI can start the daemon three ways. daemon run executes the sibling jamd --foreground with JAM_DAEMON_LAUNCHER=cli. onboard and the shared onboarding path call spawn_daemon_detached when the preflight row reports the daemon down, then poll ping 20 times at 250 milliseconds. daemon install renders and loads the platform service from jam-service (render_launchd_plist, render_systemd_unit, render_windows_task_xml, or the HKCU Run value), and the desktop is the only caller that passes --allow-bundled. All three rely on jamd itself to refuse a second instance: DaemonInstanceLock::acquire on jamd.lock early in main, after argument parsing, the lifetime lock on locks/daemon.lock, and daemon_already_serving in jam-daemon before it binds the socket.

agent_management.rs implements agent create and agent instructions. When the CLI detects that an agent runs it (JAM_AGENT_CONTEXT is truthy or JAM_RUNTIME_SESSION_ID is set), require_management_mutation refuses agent-management writes unless the allow-agent-management experiment is enabled, and experiment_toggle_refusal prevents an agent from changing that experiment or from enabling band-board-migration. copilot.rs owns the Copilot extension, skill, hook, and permission-file installation and the Copilot bridge commands. The mcp/ module serves band mcp tasks and band mcp disposition over stdio through a generic McpServer with a ToolProvider trait; the transport accepts protocol versions 2025-06-18, 2025-03-26, and 2024-11-05, caps a line at 1 MiB, and bounds each tool call at 60 seconds. The task provider binds one runtime lane at launch and exposes no runtime, room, or identity argument to the model.

The complete help text is frozen in bins/jam/src/cli-help.golden.txt. cli_contract_tests.rs::cli_help_matches_golden fails on any change, and just cli-help-update regenerates it.

#The Band peer plugin is shipped copy that shells out to band

plugins/band-peer is a Claude Code plugin registered through the repository-root marketplace .claude-plugin/marketplace.json. tauri.conf.json copies both into the bundle's resources as claude-plugin-marketplace/, and the desktop installs or updates it by running the CLI's plugin install --source <bundled marketplace> or plugin update (jam_plugin_steps in commands.rs), so the plugin transaction has one implementation in bins/jam.

The plugin has six skills (jam, jam-collab, jam-recovery, jam-resume, architect, full-stack-developer), a /jam command, and hooks/hooks.json with ten hook events: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, StopFailure, Notification, SessionStart, SessionEnd, and PreCompact. Every hook runs band hook claudecode --event <name> with a 10-second Claude Code timeout, except the AskUserQuestion matcher, which adds --ask and has a 660-second timeout.

run_hook has an exit-0-always contract: a hook failure must never break the coding session. Telemetry hooks read at most 1 MiB of stdin, persist exact-session turn-completion facts to <app>/hook-terminal-outbox/ through jam_store::hook_terminal_outbox::retain before any IPC, and then deliver the report with Client::with_timeout(socket, 150 ms). jamd drains that outbox every second in drain_hook_terminals. The ask bridge uses a normal client against ASK_AGENT_QUESTION, a held-open route in the daemon's streaming router, and prints PreToolUse control JSON only when the room answered; otherwise the native picker appears.

The standby receiver is skills/jam/jam-monitor.sh (with a PowerShell twin). It runs band receive --lease <id> --supervised --window-pid $PPID in a loop under Claude Code's Monitor tool, prints one NEW BAND MSG: line per new message, deduplicates through a seen file, holds one lock per lease in $TMPDIR, backs off after failure, re-surfaces unacknowledged messages after 300 seconds, and never acknowledges.

Every plugin command calls band without a config-directory flag, so the hooks and monitor inherit the Claude Code process environment. A session started without JAM_CONFIG_DIR reaches the default ~/.jam daemon even when the desktop runs under another directory. The desktop avoids the same problem for its own children: run_command_output_with_timeout and run_jam_daemon set JAM_CONFIG_DIR to jam_dir() explicitly.

#Packaging builds one product version in several shapes

The workspace has one version in [workspace.package].version (0.4.12 on main). just check-versions fails if a member manifest pins its own version, if apps/desktop/package.json differs, or if tauri.conf.json pins a version. jam-version reports a git describe build version in development builds and the clean version on a tag.

just bundle builds band, jam, and jamd with cargo build --release --locked -p jam --features daemon, adding the analytics features when JAM_RELEASE_ANALYTICS is true and the system-crypto SQLCipher variant on macOS. It resolves the target directory through cargo metadata, copies band to both binaries/band-<triple> and binaries/jam-<triple> (the compiled jam binary is not shipped), copies jamd, injects bundle.externalBin only for this build so pnpm tauri dev is unaffected, merges optional JAM_TAURI_BUILD_CONFIG, runs pnpm tauri build, and finishes with just bundle-smoke, which checks sidecar placement and launches.

Recipe Desktop features and cfgs Sidecar features Purpose
just bundle default; release config from environment daemon, optional analytics Installable artifact; the same recipe that release CI runs
just bundle-dev cfgs jam_desktop_multi_instance, jam_desktop_base_url_selection, jam_desktop_prefer_bundled_cli daemon, packaged-test-seams Multi-instance, selectable-endpoint development artifact
just bundle-e2e adds e2e-driver and packaged-test-seams; identifier com.thenvoi.jam.e2e; target/e2e adds e2e-observer, e2e-recovery-faults, e2e-provider-isolation Packaged WebDriver journeys

Release builds reject every test seam. just no-e2e-driver, no-e2e-observer, no-e2e-recovery-faults, and no-e2e-provider-isolation fail if the ordinary dependency graph enables those features, and just no-daemon fails if jam-desktop depends on jam-manager, jam-core, jam-store, a transport, jam-host, jam-usage, jam-discovery, ccusage, or mdns-sd. The inline copy in ci.yml ("Layering guard") checks only jam-manager, jam-core, jam-store, a transport, and jam-host. The release-binary-sanity CI job builds jamd and fails if its strings contain JAM_TEST_SBX_COMMAND, the packaged Docker fixture seam. Release staging calls .github/scripts/assert-desktop-release-build.sh, which forbids setup-wizard-test-fail-once (the desktop packaged-test-seams marker), auth.thenvoi.com, and http://localhost:4000 in the app binary, requires the "custom Band base URL selection is not compiled into this desktop build" marker, and requires the frontend tree to contain exactly one of mountFullDesktopApp or mountPublicLaunchApp for the expected surface.

CLI releases use cargo-dist (dist-workspace.toml, .github/workflows/release.yml). They ship band and jamd ([dist.binaries]), install jam as an alias of band ([dist.bin-aliases]), and build with the daemon and analytics features for four targets (macOS arm64 and x86_64, Linux x86_64 and arm64), publish a Homebrew formula and a shell installer to the public thenvoi/homebrew-tap, and never compile the desktop crate ([package.metadata.dist] dist = false in jam-desktop).

Desktop releases use release-desktop.yml, which calls desktop-release-bundle.yml with surface: full. That workflow reads a platform matrix, runs just bundle on each platform, signs (Developer ID and notarization on macOS, DigiCert KeyLocker on Windows), and stages artifacts with scripts/stage-desktop-release-artifacts.sh, which runs the release-build assertions against the executed binaries. desktop-release-publish.yml then uploads immutable versioned objects and advances one ring manifest. docs/release.md owns signing, variables, rings, and publication details.

#The updater is Rust-owned and transactional

The WebView has no updater or process permission. It calls six commands (desktop_update_eligibility, desktop_update_check, desktop_update_prepare, desktop_update_install, desktop_update_ring, and set_desktop_update_ring) and listens to desktop-update-progress. UpdateEligibility::inspect combines the compiled surface, the updater public key and endpoint base from build.rs environment, whether the process runs from an installed bundle, managed-settings policy, and the persisted ring in <app>/desktop-updater.json. The manifest URL is <endpoint base>/<surface>/<ring>/latest.json (endpoint_for), for example https://downloads.band.ai/desktop/latest/updater/full/stable/latest.json.

install_core_with_handoff in desktop_update/install.rs refuses to start while a previous transaction marker exists, checks for an update, evaluates handoff_policy over HandoffFacts (platform, daemon probe, supervisor class, and whether the daemon lives inside the install unit), downloads and verifies the payload, re-evaluates the facts, and only then writes the transaction marker. HandoffAction can be Ready, StopDaemon, StopDaemonRequired, SuspendServiceAndStop, WarnSharedDaemon, Supervised, or Blocked. AppState.desktop_update_lock allows one install at a time.

This state machine answers: which persisted updater states exist, and what does startup recovery do with each?

After the initial write_transaction of the Snapshot marker, every transition goes through UpdateTransaction::transition, which checks the allowed source phases and persists the candidate atomically before changing the in-memory value (tests desktop_update_rejects_illegal_transaction_transitions and desktop_update_failed_persist_does_not_advance_in_memory_transaction_state). Every state except ServiceSuspensionPending carries a RuntimeRecovery of None, RestartUnsupervisedDaemon, RestoreService, or ReloadAppOwnedService; ServiceSuspensionPending implies RestoreService. The install path also calls rollback_pre_apply from HandoffComplete when mark_apply_started fails, and from ApplyStarted when the bundle install fails with restore_safe. At the next launch, recover_interrupted_update restores the pre-apply runtime for the three pre-apply states and clears the marker, finishes recovery for ApplyStarted when the running version satisfies the target, and otherwise records PostApplyFailed and leaves the marker for manual reinstall. A corrupt, unreadable, or incompatible marker yields Blocked and is never deleted (test desktop_update_recovery_blocks_on_a_corrupt_marker_without_deleting_it).

#What a macOS install puts on disk

The files an installed desktop places or creates, and the component that owns each:

The bundle's MacOS directory holds four executables; band and jam are byte copies of the same CLI build. ensure_managed_cli writes anchors under the install-state directory from jam_service::integration_install::install_state_dir (on macOS ~/Library/Application Support/jam, overridable with JAM_INSTALL_STATE_DIR) and user links in ~/.local/bin. Every managed artifact is written with write_managed_artifact(mode, path, bundled) where bundled is sidecar::bundled_cli(), which prefers band, so ~/.local/bin/band and ~/.local/bin/jam both link to Band.app/Contents/MacOS/band (observed on a macOS install running a 0.4.10 development build). The config directory ~/.jam is created with mode 0700, and the socket with mode 0600 (jam-daemon::serve_with_timeout_and_ready). The SQLCipher master key db:master and per-entity secrets live in the keychain service com.thenvoi.jam, with a 0600 cfg/secrets file fallback when the keychain is unavailable. The two LaunchAgents exist only when background mode is on: the service plist is rendered by jam_service::render_launchd_plist and written by band daemon install, and the tray login item is written by tauri-plugin-autostart with app name com.thenvoi.jam.tray and arguments --tray --config-dir <dir>. Inferred: the tray plist path follows the auto-launch 0.5.0 crate's ~/Library/LaunchAgents/{app_name}.plist convention; this chapter did not observe a machine with background mode enabled.

#State and ownership

Aggregate Key and scope Authority Mutation coordinator Durable representation Projections Freshness or fence Recovery Deletion authority
Daemon connection status One per desktop process jam_ipc::monitor ping results Monitor task only Memory (AppState.conn watch) conn-event, useDaemonStore.connStatus, tray face and menu Transition-deduplicated; 5 s or 2 s ping Monitor loops until cancelled Not applicable
Daemon lifecycle health One per desktop process Startup bootstrap, tray stop, start_daemon, autostart commands events::update_daemon_health after startup Memory mutex in AppState daemon-health-event, banner via mount-time daemon_health read Read on mount plus events Recomputed at next launch Not applicable
Server-derived frontend state One store per WebView (main and each room-* pop-out) jamd useDaemonStore actions: hydrate* and applyEvent None; persist writes an empty object Component selectors Event order plus module-level generation maps; full rehydrate on mount, reconnect, resync Rebuilt from jamd Not applicable
Client UI state One per WebView The user useUiStore, useNav Memory; nav collapse in localStorage Layout None Defaults on launch Not applicable
Updater ring Per config directory User, overridden by managed settings desktop_update::write_ring <app>/desktop-updater.json Eligibility result Managed lock checked on write Absent file means compiled default ring User selection
Update transaction One per config directory desktop_update::install and recovery UpdateTransaction::transition under desktop_update_lock <app>/desktop-update-transaction.json, schema version 1 Recovery verdict gating startup Allowed-phase table; persist before in-memory change recover_interrupted_update at launch Recovery clears it only after restore or completion
Managed CLI links Per OS user cli_install::ensure_managed_cli Install-state lock with 3 s timeout, CLI journal stages Anchors, links, cli-install.json, integration-install-v1.json, cli-transactions/ cli_resolution, terminal CLI status Fast path compares version, targets, and link destinations recover_managed_cli_locked replays the journal uninstall_cli and desktop-uninstall-cleanup, which remove only recorded artifacts
Background service and tray login item Per OS user and config directory band daemon install or uninstall Desktop daemon_autostart shells to the bundled CLI; login_item::reconcile follows it LaunchAgent plist, systemd unit, Task Scheduler task or HKCU Run value; tray plist autostart_status, supervisor detection restage_if_stale compares the recorded jamd path Restaged on normal launch; stale --tray item self-disabled The user's toggle
Daemon ownership of a config directory Per config directory The running jamd OS advisory locks jamd.lock (instance) and locks/daemon.lock (lifetime) daemon_lifetime_held probe Process death releases both Not needed Not applicable
Setup-wizard completion Per config directory The first-run wizard complete_setup_wizard <app>/setup-wizard-completion.json Main window route Schema-versioned; unknown is incomplete Missing or malformed means incomplete Not applicable
Hook terminal facts Per config directory The band hook process hook_terminal_outbox::retain <app>/hook-terminal-outbox/, 4,096 entries, 1 KiB each Delivered activity in jamd Immutable receipt names jamd drains every second Receipt acknowledgement removes its own entry
Plugin installation Per OS user Claude Code's plugin manager, driven by band plugin run_manual_claude_transaction under the integration lock Claude Code's plugin cache; integration record Plugin health in Settings Plugin version in plugin.json band plugin install --source repairs band plugin uninstall
Notification preferences Per installation jamd settings Manager::set_notification_preferences settings row in the store NotifyState in the desktop, notificationRevision in the store NotificationPreferencesChanged event, re-read in TauriSink Loaded when the monitor sees Online Not applicable
Desktop logs Per config directory The desktop process tauri-plugin-log folder target <app>/logs/desktop.log, 10 MiB per file, dated rotations kept Support bundle Size-rotated Not needed Manual

#Contracts

#Provided to people and scripts

  • CLI surface. The Cmd tree and its help text, frozen by cli-help.golden.txt. band is canonical and jam is a compatibility alias with the same implementation. The global options and their environment variables (JAM_CONFIG_DIR, JAM_PROFILE, JAM_SESSION) are part of the contract because the plugin and hooks depend on inheritance.
  • Hook protocol. band hook <provider> --event <name> [--ask] reads the provider's JSON payload on stdin, always exits 0, and writes only PreToolUse control JSON to stdout when the ask bridge has an answer. JAM_ACTIVITY_DISABLED=1 and JAM_QUESTION_BRIDGE_DISABLED=1 disable telemetry and the ask bridge.
  • MCP servers. band mcp tasks and band mcp disposition speak JSON-RPC 2.0 over newline-delimited stdio, write only protocol frames to stdout, render tool failures as isError results, and never accept a runtime, room, or identity selector from the model.
  • Service files. jam-service owns the label com.thenvoi.jam, the systemd unit name jam.service, the Windows task and Run-value names, the renderers, and the parsers. The CLI writes them and the desktop only reads them.
  • Release identity. band-desktop --write-desktop-release-identity <path> writes schema-2 JSON with version, platform, architecture, surface, updater endpoint base, public key, default ring, and analytics capabilities. Release staging attests artifacts with it.
  • Updater manifests. Static latest.json per surface and ring, produced by scripts/generate-desktop-updater-latest-json.sh and checked by just updater-manifest-check.

#Provided inside the desktop

  • Tauri commands. 250 commands registered in collect_commands! in specta_builder. bindings.ts is generated from the same builder, and the two isolation allowlists must list the same names. Daemon-proxy commands return Result<T, IpcError>. Several desktop-local commands return a plain value (for example app_version, desktop_update_check, install_cli, and cli_resolution). desktop_update_install, set_desktop_update_ring, and four analytics commands return Result<T, String>, which unwrapString handles for analytics. track_analytics_event and report_error return nothing.
  • Tauri events. Ten typed events from collect_events!: conn-event, daemon-event, daemon-health-event, desktop-update-progress, integration-setup-event, linear-connect-event, log-line, open-chat, resync-event, and sign-in-event. Delivery is best-effort and in order per emitter. daemon-health-event and integration-setup-event are nudges; each has a mount-time command read that is the source of truth.
  • ipc surface. The flat ipc object and subscribe* functions in ipc.ts. Every call throws IpcFailure with a typed IpcError, and every AuthRequired failure triggers the shared reauthentication path.

#Consumed from jamd

  • Control through jam_client::Client. The crosswalk lists 255 methods; 195 are reachable from a Tauri command and 169 are called by the CLI crate. 243 methods have default bodies, so a missing override in a Control implementation compiles and fails at run time.
  • Admission and timeouts. One Client admits at most MAX_UNARY_IN_FLIGHT (16) concurrent unary requests through a tokio::sync::Semaphore. The default unary deadline is 10 seconds and includes admission. post_timed and get_timed take a permit with a longer route budget. post_untimed (for example wait_inbox) and post_held_timed (bounded waits such as connect_wait, sized by connect_wait_ceiling) take no permit, so waits cannot starve ordinary commands. The event and log streams take no permit and end when their cancellation token fires. The HTTP pool uses pool_max_idle_per_host(0), so every request opens a fresh local connection. The daemon logs a warning each time its concurrent local request count reaches a new high-water mark that is a multiple of 16 (LocalRequestTracker).
  • Wire version. PingResp.wire is jam_wire::WIRE_VERSION, currently 30. Client caches the negotiated value in negotiated_wire and refuses newer calls against older daemons before sending them; the jam-client tests a_pre_wire_30_daemon_refuses_executable_selection_before_the_route_is_sent and the pre_v* family cover individual gates.
  • Events. GET /v1/events is newline-delimited JSON with no replay. The events reference lists all 59 kinds. Clients must treat a gap or stream_resync as a request to re-hydrate.

#Invariants

Rule Enforced by Known exceptions
The desktop crate never compiles the daemon stack. just no-daemon and its narrower inline copy in ci.yml (cargo tree -p jam-desktop grep) None. jamd ships as a prebuilt sidecar, which adds no crate dependency.
collect_commands!, bindings.ts, src-isolation/index.js, and src/lib/isolation.ts list the same commands. bindings.rs::bindings_match_committed_contract (byte-equal regeneration); isolation.test.ts "runtime hook (src-isolation/index.js) allowlist matches the spec exactly" and "allowlists every command exported by the generated Tauri bindings" plugin:* messages bypass the allowlist by design.
bindings.ts has one writer. just bindings sets BINDINGS_REGEN=1; the default test only compares Debug run also calls export_bindings on the source file during tauri dev.
Components never write server-derived state directly. Convention plus review; no lint rule LargeAccountPerf.fixtures.ts sets the store for a performance fixture. Module-level fence maps in daemon.ts hold freshness state outside Zustand.
Server-derived state is never persisted in the WebView. partialize: () => ({}) in useDaemonStore None.
An always-mounted surface does not refetch on unrelated event churn. ipcBudget.ts tests on each covered surface Only surfaces with a budget test are protected.
A late check result never replaces a timeout, cancellation, or newer attempt. Generation counters in useLatestChecks; useLatestCheck.test.tsx The underlying IPC call is not aborted.
The WebView can load only the app's own origin and open only http, https, or mailto URLs through Rust. nav_guard::is_allowed_navigation, is_openable_url, capability denials, capability_security.rs, no-raw-anchors.test.ts Development server origin http://localhost:1420.
No secret reaches the WebView through ordinary DTOs. Redacted DTOs in jam-ipc; OAuth progress events carry no verifier, code, token, or key The explicit reveal_agent_api_key command.
The desktop never starts a second daemon against a config directory that a daemon still owns. wait_for_release checks socket and locks/daemon.lock; jamd instance lock and daemon_already_serving; jamd_managed_restart.rs::daemon_lifetime_lock_fences_a_duplicate_process_before_store_or_ipc_open Lock I/O errors are treated as held, which can refuse a legitimate spawn.
The desktop never replaces a daemon that an external supervisor owns. decide returns NotifyForeign or AwaitSupervised; daemon.rs tests notify_foreign_when_drift_is_supervised, await_supervised_when_nothing_listening_yet_but_supervised None.
RELEASE_WAIT outlasts jamd's force-exit backstop. daemon.rs::release_wait_outlasts_the_daemon_force_exit The test compares against a local copy (DAEMON_FORCE_EXIT); nothing checks it against FORCE_EXIT_GRACE in jamd.rs.
No normal startup owner runs while an update transaction is unrecoverable. RecoveryOutcome::allows_canonical_startup in the setup hook; desktop_update_recovery_outcome_gates_canonical_startup None.
A persisted update transaction advances only along allowed phases. UpdateTransaction::transition; desktop_update_rejects_illegal_transaction_transitions Legacy markers are read through LegacyTransactionPhase (test desktop_update_reads_legacy_transaction_markers).
Release artifacts contain no test seam or development endpoint. no-e2e-* recipes, release-binary-sanity string scan, assert-desktop-release-build.sh just bundle-dev and just bundle-e2e artifacts intentionally contain them and are never published.
Only one product version exists. just check-versions Development builds report a git describe string.
Hooks never break the coding session. run_hook returns Ok on every path; 150 ms client The ask bridge can hold a hook for up to Claude Code's 660-second timeout by design.
The CLI help text changes only deliberately. cli_contract_tests.rs::cli_help_matches_golden None.

#Failure and recovery

Daemon absent at launch. decide returns Spawn unless a supervisor owns the directory. If the spawn fails or the socket stays occupied past RELEASE_WAIT, bootstrap records DaemonStartupOutcome::Failed and continues; the monitor reports Offline, commands fail with DaemonOffline, and the offline panel offers start_daemon, which reruns ensure_running_ready and waits for identity. The desktop keeps its window, tray, and store and rehydrates when the monitor next sees Online.

Daemon restart or crash while the desktop runs. The event stream ends, the relay resubscribes after 2 seconds, and the first event after the gap triggers resync-event, which resets lane and inbox watermarks and participant snapshots and runs rehydrate("reconnect"). Independently, the monitor's Offline then Online transition runs the same rehydrate. Coalescing prevents two concurrent passes. In-flight unary requests fail with DaemonOffline or RequestTimeout; nothing retries a mutation automatically.

Event loss without disconnect. An over-cap line produces an in-band StreamResync, handled as a gap. The daemon's fan-out broadcast (CHANNEL_CAP of 256 in fanout.rs; see Messaging and room state) can also lag a slow subscriber. Control::events for Manager calls event_stream(cancel, true), which on RecvError::Lagged resubscribes with a room snapshot and pushes StreamResync ahead of it, so the desktop runs the same resync and rehydrate (manager test lagged_fanout_emits_stream_resync covers the marker).

Saturated client. When 16 unary requests are in flight, the next caller waits for a permit inside its own deadline and fails with RequestTimeout, not DaemonOffline. Streams and held waits are unaffected. The monitor uses its own Client, so a command storm cannot make the app report offline. The daemon's high-water log identifies which routes caused the burst.

Slow bootstrap. Startup steps run synchronously in the setup hook. Worst cases are the 25-second restage_if_stale shell-out, wait_for_release up to 46.5 seconds, and spawn_ready with three 5-second identity waits. The main window is already visible with a static status, and desktop.log records the last started stage. Frontend account bootstrap in useAccountGate retries at 400 ms for up to 10 attempts within a 5-second budget before showing a startup error with retry.

Foreign or supervised daemon. A version-mismatched daemon under a supervisor is used as is, with a banner that names band daemon uninstall and a native notice that names jam daemon uninstall. A Stop that a supervisor defeats yields StopBlocked. A moved app bundle is repaired by restaging the service on the next normal launch; a stale tray login item disables itself.

Interrupted update. Any failure before apply rolls back the recorded runtime recovery and clears the marker. A crash during handoff or apply leaves the marker, and the next launch restores or completes recovery before other owners run, or blocks canonical startup and keeps the marker when it cannot. The install command refuses to start while a marker exists.

Interrupted CLI installation. Managed CLI writes are journaled (CliJournalStage::AnchorWritten, TargetWritten) under the install-state lock, and recover_managed_cli_locked replays or rolls back on the next reconciliation. A foreign file at a target path is left in place and reported. An unreadable ownership record aborts with "no commands were changed".

Interrupted macOS rename. desktop_migration uses one atomic no-clobber rename, so a crash leaves either jam.app or Band.app intact. Checks before the rename must not fail launch (test nothing_before_the_rename_can_refuse_to_launch_the_installation); a failure after it shows an alert and exits rather than starting Tauri with a stale path.

Session expiry. Any command that returns AuthRequired triggers the shared reauthentication dialog and one coalesced account hydration. The daemon owns the token refresh; the desktop only re-prompts.

Hook delivery failure. The hook exits 0. Turn-completion facts survive in the outbox until jamd drains them, including across a daemon restart. Other activity reports are best-effort and lost if the 150 ms delivery fails.

Monitor failure. The standby monitor backs off after a failed band receive, keeps one waiter per lease through its lock file, and resumes after a daemon restart because the lease lives in jamd.

#Extension points

#Adding a desktop command for a daemon operation

A daemon operation crosses nine layers. The steps are:

  1. Add the method to jam-contract::Control, usually with a default body.
  2. Implement it in jam-manager and override the trait method in the impl Control for Manager block in manager.rs.
  3. Add the request and response types and route constant to jam-wire.
  4. Add the handler and .route(...) in jam-daemon, choosing the unary or streaming router, and extend unary_timeout_for_route if the handler can exceed 45 seconds.
  5. Add the jam-client method through post, post_timed, or a held path. If the route spawns a runtime child, add it to jam_wire::RUNTIME_CHILD_ROUTES so both sides get the child budget automatically.
  6. Add the jam-ipc::Commands method and an IpcError mapping test in crates/jam-ipc/tests/ipc.rs.
  7. Add a #[tauri::command] #[specta::specta] function in commands.rs and register it in collect_commands!.
  8. Add the snake_case name to src-isolation/index.js and src/lib/isolation.ts.
  9. Run just bindings, add the ipc member in ipc.ts, and use it from a store action or component.

Steps 7 through 9 are the desktop-only part and are enforced by the bindings freeze test and the two isolation parity tests. A desktop-local command skips steps 1 through 6.

#Adding an event kind

Add the variant to EventKind in crates/jam-domain/src/event.rs and publish it from the owning manager code. Add a case in applyEvent in src/stores/daemon.ts, or state why the desktop ignores it; the events reference marks unhandled kinds. If the event should notify while the window is hidden, add the side effect in TauriSink::event after the DaemonEvent emit. If the event replaces or invalidates a slice, decide what resync must reset. Add an IPC budget test if the new case could cause a surface to refetch.

#Adding a coding-agent harness: client-side touch points

The transport catalog carries most per-transport facts to the desktop. runtime_transport_catalog returns HostTransport::catalog(), and src/lib/runtimeTransports.ts derives the create picker and settings forms from it; src-tauri/tests/transport_catalog_fixture.rs checks the desktop fixture against the catalog. After the domain and jam-host work described in Providers and attached agents and the extension seams assessment, the client still needs:

  • Desktop copy that switches on the generated type. PROVIDER_BY_TRANSPORT in components/blocks/ProviderIcon.tsx is a Record<RuntimeTransportView, ...>, and providerCardCopy in LocalAgentCreatePanel.tsx and transportLabel in RuntimeTemplatePanel.tsx switch over HostTransport. TypeScript rejects a missing case after just bindings. The seven HostTransport literals appear as quoted strings in 25 frontend files outside tests, stories, and fixtures, led by LocalAgentCreatePanel.tsx (18 occurrences), RuntimeTemplatePanel.tsx (8), and runtimeDraft.ts (6). The count includes "acp" and "pty", which can also match unrelated strings.
  • Integration setup. jam_service::integration_install::ProviderKey has four variants (Claude, Copilot, Codex, Opencode). ProviderKey:: appears 132 times in bins/jam/src/main.rs and 7 times in apps/desktop/src-tauri/src/integration_setup.rs. A harness that Jam detects or installs into needs a new variant and both sets of match arms, and a new icon in iconByProvider.
  • CLI integration. Attached harnesses that install assets follow copilot.rs (6,351 lines, provider-specific). Hook-based harnesses need a provider name accepted by band hook <provider>, and band mcp install --target accepts only codex, claude, or auto.
  • Plugin or skill copy. A harness with its own onboarding skill needs public text that stays stack-independent and a plugin version bump (plugins/band-peer/AGENTS.md).
  • Packaged E2E. A scenario in apps/desktop/e2e/real-agents/scenario-catalog.mjs.

#Adding a CLI command

Add a Cmd variant or subcommand enum in main.rs, add its arm to the offline pre-dispatch in run_inner if it must not dial the daemon, or to the main dispatch otherwise, and decide whether nudge_eligible_command should offer the update nudge after it. Regenerate the golden help with just cli-help-update and review the diff. If the command mutates agents and can run inside an agent, route it through require_management_mutation.

#Adding a navigation section

Extend the NavSection union in src/lib/nav.ts, the exhaustive productAreaForSection switch (a new analytics area needs privacy review, so return undefined otherwise), SCREEN_KEYS and TOP_LEVEL_SECTIONS in src/stores/managedSettings.ts, the section branches in App.tsx, and NavRail.tsx. If the section is experimental, register a key in crates/jam-manager/src/experiments.rs, mirror it in src/lib/useExperiments.ts, and gate it like inbox-section. Run just visual-test for rail changes.

#Adding a build flavor or test seam

Add the Cargo feature to the owning crate, enable it only in scripts/bundle-dev.sh or the bundle-e2e path, add a just no-<feature> graph guard to rust-check, and add a string marker that assert-desktop-release-build.sh or release-binary-sanity forbids in release artifacts.

#Refactor notes

Oversized files concentrate unrelated concerns. bins/jam/src/main.rs is 27,943 lines and holds the clap tree, dispatch, onboarding, plugin and integration transactions, daemon service installation, hooks, statusline, usage printing, and tests. commands.rs (6,176 lines) mixes 198 one-line proxies with sign-in orchestration, CLI resolution, plugin installation, and shell-out helpers. daemon.ts (7,554 lines) holds every slice, 27 hydrators, a 57-case reducer, and 63 module-level fence variables. App.tsx (5,118 lines) owns the bootstrap effect, section routing, and many feature callbacks. LocalAgentCreatePanel.tsx is 5,317 lines.

Boilerplate per operation is high and mostly mechanical. A daemon operation needs a Control method, a manager override, wire types and route, a daemon handler, a client method, a jam-ipc method, a Tauri command, two allowlist entries, and an ipc.ts member. Tests close the loop on the desktop side (bindings freeze and allowlist parity), but the Control-to-route-to-client portion relies on the generated crosswalk to find gaps, and 243 default method bodies turn a missing override into a run-time "not supported" error.

Two clients per desktop process with separate budgets. AppState holds one Client and conn::spawn builds another for ping and events. Ad hoc clients appear in commands.rs for wire-version checks and in daemon.rs for 1-second probes. The 16-permit budget is therefore per Client, not per process. This isolation keeps the monitor alive during command storms, but it is implicit.

The client and daemon disagree on the ordinary unary deadline. The client default is 10 seconds and the daemon default is 45 seconds, with per-route exceptions maintained in two places (Client::runtime_operation_timeout plus explicit post_timed call sites, and unary_timeout_for_route). RUNTIME_CHILD_ROUTES and room_read_policy_for_route already share one list across both sides; the remaining exceptions do not.

A second ping client exists outside jam-client. daemon_identity_probe on Unix writes a raw HTTP/1.1 request to a blocking UnixStream and parses the response itself, because the setup hook has no async runtime. Any change to /v1/ping or its response must be mirrored in parse_ping_identity.

Duplicated lifetime constant. DAEMON_FORCE_EXIT in daemon.rs restates FORCE_EXIT_GRACE in jamd.rs (both 45 seconds). The comment says the duplicate is "guarded by the regression below", but that test only checks RELEASE_WAIT against the local copy.

Two daemon locks with overlapping purpose. jamd takes DaemonInstanceLock on <app>/jamd.lock early in main, after argument parsing (plus a wait for legacy per-peer locks) and later jam_service::try_acquire_daemon_lock on <app>/locks/daemon.lock. Both guards live for all of main. The desktop probes only the second. Inferred: one of them is redundant for the desktop's purpose, but the instance lock also covers the window before deployment configuration is resolved, so removing either requires checking that window.

Startup policy lives in the desktop, not in a shared crate. Version lockstep, supervisor detection, restaging, and release waiting are in apps/desktop/src-tauri/src/daemon.rs. The CLI has its own simpler start path (spawn_daemon_detached, daemon_run) that does not check versions or the lifetime lock and relies on jamd refusing duplicates. jam-service owns only the file formats and the lock.

The thin CLI is not fully thin. Its default graph includes jam-store (for init, stats, logout fallback, on-prem, reset, managed user defaults, and the hook outbox) and jam-host with runtime-tools (for the Claude hook parser and task MCP tool names). The generated crate graph shows both. These paths bypass the daemon for durable state.

Blocks are mostly, not entirely, presentational. MessageContent.tsx imports the ipc value to open links. The other 12 block files that import from ipc.ts import types.

Provider knowledge is spread through client code. Beyond the catalog-derived paths, the client layers name providers in ProviderKey matches (139 references across main.rs and integration_setup.rs), in copilot.rs, in the claudecode hook provider and outbox filter (hook_terminal_outbox::retain accepts only provider == "claudecode"), and in three exhaustive TypeScript maps. Extension seams quantifies the rest of the system.

Contradictions between documentation and code that a refactor should resolve:

  • The root AGENTS.md architecture bullet says jam_wire::WIRE_VERSION "(=1)"; the constant is 30.
  • The same bullet says "The default stays --auth mint"; AuthMode in main.rs defaults to Token, and the later "Authentication and account isolation" section of AGENTS.md agrees with the code.
  • docs/release.md ("Installed macOS layout") says ~/.local/bin/jam links to the bundled jam compatibility command; cli_install.rs points every managed artifact at bundled_cli(), which prefers band, and an installed machine shows both links pointing at Band.app/Contents/MacOS/band. The bundled jam is itself a copy of band (just bundle copies the band binary to jam-<triple>).
  • docs/release.md names release jobs bundle-macos-full, bundle-linux-full, and bundle-windows-full; no workflow defines those job names. desktop-release-bundle.yml has one matrix bundle job.
  • docs/release.md ("macOS signing & notarization") says the verifier confirms the mounted jam.app; the product name is Band, and scripts/verify-notarized-macos-bundle.sh accepts exactly one *.app and checks its bundle identifier.
  • The comment on MAX_LOG_BYTES in lib.rs says both the desktop log and jamd.log are size-bounded; the desktop uses RotationStrategy::KeepAll, which in tauri-plugin-log 2.8.0 renames each full file with a date and keeps it, so only each file is bounded, not the total. Dated desktop_*.log files accumulate in logs/.
  • Comments in lib.rs refer to the tray item "Quit jam"; the menu label in tray.rs is "Quit Band".
Scroll to zoom, drag to pan.