# AGENTS.md This file provides guidance to coding agents working with this repository. ## Project Overview BOSS (Business Operating System Service) is a desktop application built with Kotlin Multiplatform and Compose Multiplatform. It features WebAuthn/passkey authentication, integrated browser (JxBrowser), terminal integration (BossTerm), customizable keyboard shortcuts, and role-based access control. **Target Platforms**: Desktop only (macOS, Windows, Linux). ## Essential Commands ```bash ./gradlew run # Run desktop application ./gradlew showVersion # Display current version ./gradlew test # Run tests ./gradlew build # Build application ./gradlew packageDmg # Build macOS DMG ./gradlew packageMsi # Build Windows MSI ./gradlew packageDistributionForCurrentOS # Linux packages ./gradlew incrementVersion # Increment patch version ``` ## Workflow Rules **IMPORTANT**: Do NOT run `./gradlew run` in a blocking/foreground way just to test - the user runs and tests the app themselves. **Exception:** launching the app **in a dedicated bottom split pane is allowed** (backgrounded so it doesn't wedge the pane). ### Testing automatic app updates On macOS, closing the last main window keeps BOSS running in the Dock. A Dock click or File > New Window opens a window again; explicit Quit ends the application. Automatic Updates in Settings downloads new releases and prepares installation after manual quit. On macOS it also installs when no main windows remain, including when the download finishes after the last window closes. That idle update relaunches with `--no-window`, keeping BOSS in the Dock until a Dock click or New Window request. Explicit Quit installs the staged update and leaves BOSS closed. Release distributions default this on; Gradle development runs default it off. Explicit preferences are saved. Update banners and prompts stay hidden in automatic mode; progress and errors remain in Settings. The next start of a newer version shows a one-time success toast. Build a separate older distribution for installation testing: ```bash ./gradlew :composeApp:createDistributable -PtestAutoUpdate=true ``` This uses v1.0.0, enables automatic updates by default, stores updater preferences in `composeApp/build/auto-update-test-settings`, and writes the distribution under `composeApp/build/compose-auto-update-test/binaries/main/app/`. On macOS, manually launch `BOSS.app` from that directory. Quit manually to let the helper install, then open it again after installation finishes. Closing all main windows instead tests the idle install and windowless relaunch. The incoming release must support `--no-window` to remain windowless after installation. Native Quit requests remain pending through Compose cleanup and then complete, so BOSS does not cancel macOS logout, restart, or shutdown. Do not use `run` for installation testing: the updater refuses automatic installation from a Gradle development process. ### Automatic plugin updates Settings > Updates has an independent Automatic Plugin Updates switch. It defaults on in packaged release builds and off in Gradle development runs. Preferences persist. Every eligible plugin is included by default. The per-plugin list stores explicit opt-outs, so newly installed plugins update automatically too. Opt-outs are rechecked before activation. The worker starts after workspace restoration, uses compatible store candidates, and waits for all plugin tabs and mounted panels to close. It rechecks views after download; a reopened view defers installation again. Downloads use `.part` files until admitted. Failed promoted artifacts are quarantined; a `.rejected-update` marker fences startup selection if a live loader prevents removal. Host and Toolbox updates/installVersion share SHA-256 plugin-ID file locks under `.plugin-update-locks`; never delete lock files. Both participants claim a process gate before opening a lease descriptor. Their String-only system properties use keys `boss.plugins.updateLease.protocol3.` followed by SHA-256 of the UTF-8 canonical lock-file path, with UUID String ownership tokens. Capture the Properties object and computed key once per acquisition. Properties.putIfAbsent admits exactly one owner; every existing value is busy, including foreign values. Never replace an occupied value. Remove only the exact owning String token after confirmed successful channel closure. No host/plugin objects or access contexts enter global state; Properties.store/list remain string-compatible. Keep this protocol identical to Toolbox's PluginUpdateProcessRegistry and PluginUpdateLease. Closing a second same-process descriptor can release the holder's POSIX OS lock, so busy contenders must never open it. isOpen=false alone is not proof after a failed native close; any uncertain close retains its token across repeated closes and requires restarting BOSS. Registry mutation, clearing properties, or System.setProperties by arbitrary in-process code is outside this cooperative protocol; plugins already share JVM/file access. Both host and Toolbox releases must use protocol 3 for the shared guarantee. Persistent disk lock names remain SHA-256 of the plugin ID. Ordinary idle plugins reload without restarting BOSS; native plugins, disabled plugins, plugins with loaded dependents, and multiwindow updates are staged for the next manual start. Disabled plugins remain disabled. Protected API/runtime ids use their existing host-managed lifecycle. Activation failures attempt to restore the previous JAR; status and errors appear in Settings. Supabase Realtime is the primary trigger, with a six-hour fallback and a local 30-second check for views closing. Three failed attempts pause that release for this process; a new release gets a fresh budget. Busy views and another installer's lock do not consume attempts. Toolbox honors the optional `boss.plugins.autoUpdate.enabled` JVM property to suppress manual update toasts in automatic mode. The `boss.plugins.autoUpdate.optOuts` property keeps manual prompts available for explicitly excluded plugins. Its companion change needs to ship too; older Toolbox builds may still show a manual prompt. Turning the mode off stops pending work before activation; already-staged updates apply at the next manual start. ### `composeApp` test home isolation Every `composeApp` `Test` task points `user.home` at its own fresh `composeApp/build/test-home/` directory. Keep the redirect and the per-run deletion: `ProjectState` persists recent projects asynchronously and retains only ten, so tests using the real home can evict entries from a developer's project picker and a reused test home makes later runs race a stale `recent-projects.json` load. This guarantee is deliberately module-local; moving a test that reads `BossDirectories.rootDir` to another module requires equivalent isolation there. ### A `@Test` that returns a value never runs `fun x() = runBlocking { ...; assertIs(y) }` returns whatever its last expression does, and JUnit Jupiter does not execute a `@Test` method that returns a value: it reports a WARNING-level discovery issue, which Gradle does not print, and counts the method nowhere. Nine `composeApp` tests sat unexecuted that way, four of them the MCP approval gate's fail-closed guarantees, and one no longer described the code by the time it first ran (#1667). Declare an expression-bodied test `(): Unit =`, or write `runBlocking`, as `plugin-loader`'s tests already do. Every module's Gradle `Test` task now sets `junit.platform.discovery.issue.severity.critical=WARNING` (root `build.gradle.kts`), `buildSrc` sets it in its own build file because it is a separate build the root never reaches, and `composeApp` repeats it in `src/desktopTest/resources/junit-platform.properties` so an IDE run meets it too. A discovery issue aborts discovery for the whole engine: every test in the module disappears and a single `initializationError` names the method. If a toolchain bump introduces an unrelated warning, relax the property to `ERROR` in that module's own `build.gradle.kts`, which wins over the root; do not delete it. **The property is only read by JUnit Platform 1.13 and later.** On an older platform it is silently ignored, the guard does nothing, and a passing test run looks exactly the same as a working guard. That is why `buildSrc` imports the root version catalog and pins `platform(junit-bom)` to `libs.versions.junit.jupiter` (#1709): its only other test dependency, `kotlin("test")`, resolves JUnit through Gradle's embedded Kotlin (Platform 1.10), and the guard there was inert until the pin. The BOM is not a redundant dependency; removing it restores the inert guard. To check a module, run a throwaway `@Test fun probe() = 42` and confirm the run fails with "must not return a value". ### Running commands in a visible terminal pane When a terminal MCP server is available, prefer it over the plain `Bash` tool for commands worth showing - it runs in a visible BossTerm pane and still returns stdout/stderr/exit code. Two servers may be present depending on which app hosts the session; use whichever the session's `SessionStart` hook designates: - **`mcp__boss__*`** - exposed by the `terminal-tab` plugin inside BossConsole (e.g. `mcp__boss__run_command`, `run_in_sidebar`). - **`mcp__bossterm__*`** - exposed by the standalone BossTerm app. For a bottom split use `panel: horizontal_split`. Reuse a pane across calls by passing back its `pane_id`. Keep plain `Bash` for trivial read-only commands where opening a visible pane is churn. (Do not mix the two servers in one session - they target different app instances.) ## Architecture ### Module Structure - **`composeApp/`** - Main Compose Multiplatform UI application - **`server/`** - Minimal Ktor server component - **`modules/`** - Microkernel / out-of-process architecture Gradle modules (`boss-ipc`, `boss-service-*`, `boss-orchestrator`, `boss-ui-sdk`, `boss-mastery-*`, `boss-app-*`). They keep flat Gradle paths (`:boss-ipc`), only the directory lives under `modules/`; excluded on Windows-ARM64 (no protoc binary). `boss-ipc`/`boss-ui-sdk` publish as upstream jars consumed by the standalone `boss-microkernel-runtime` repo. - **`plugin-platform/`** - Host-side plugin platform / SDK modules (`plugin-loader`, `plugin-repository`, `plugin-api-core`, …). This is the infrastructure that loads and runs plugins, **not** the plugins themselves - those live in the separate `boss_plugins` repo. - **`supabase/`** - Database migrations and Edge Functions ### External Dependencies - **BossEditor** (`com.risaboss:bosseditor-compose-desktop`) - Standalone code editor with LSP and PSI support. **Not a host dependency** - bundled privately inside the `editor-tab` plugin, like BossTerm inside `terminal-tab` (see [docs/BOSSEDITOR.md](docs/BOSSEDITOR.md)) ### Key Technologies - Kotlin Multiplatform + Compose Multiplatform - JxBrowser (version pinned in `gradle/libs.versions.toml`, with BOSS-branded Chromium) - Decompose for navigation - Supabase + Edge Functions - BossTerm for terminal integration (bundled in the `terminal-tab` plugin) ## Remote UI property patches are tri-state `WidgetDiffEngine` must distinguish three operations on a widget property: leave it alone, set it to a string, and remove it. An empty string cannot mean removal. It is a valid value for text, labels, selections and event ids, and the builder emits both omitted optional properties and present empty required properties. `DiffOperation.NodeUpdated.changedProperties` therefore carries assignments, while `removedProperties` carries deletes. The wire mirrors that split through additive `NodeUpdated.removed_properties` field 4, introduced with IPC 1.2.0. `apply` removes first and then applies changed values, so an explicit set wins if a malformed or hand-built patch names one key in both collections. Encoders sort removed keys for deterministic bytes. Older receivers safely ignore field 4; they retain a stale property until a full tree arrives, which is the existing failure rather than a new one. `NodeUpdated` is a manually implemented plain class rather than a data class now because `boss-ui-sdk` is published to external runtimes. Keep its original three-argument constructor, `component1` through `component3`, `copy`, and generated `copy$default` JVM descriptors. New code that needs to alter the removal set uses the four-argument constructor or `copyWithRemovedProperties`. `WidgetDiffEngineTest` reflects the old descriptors so a refactor cannot silently break an already-built runtime. ## Plugin dependencies are resolved at install time `plugin.json` `dependencies` used to be read in exactly one place - `DynamicPluginManager.checkCanUnload`, which refuses to uninstall a plugin something else depends on. Nothing looked at them when a plugin was *installed*, so installing a plugin whose dependency was absent produced no signal at all and the user met the consequence later, as a feature that silently did nothing. The AI Gateway made that concrete: three plugins declare it `optional: true` and each falls back to an unconfigured state. **Both readers now agree on what `optional` means.** `checkCanUnload` counted *any* declaration, so the same `optional: true` that the install prompt words as "works without it" was a hard veto at unload time - and because the Toolbox updates a plugin by uninstalling and reinstalling it, the veto landed on the Update button. The AI Gateway could not be updated or removed while jupyter-notebook, flow-tab or llmrpa was loaded, which is all three of its consumers. The predicate is `PluginDependencyResolution.blockingDependentsOf`, next to `missingFor` so the two cannot drift again; it also drops a manifest that names itself, which would otherwise make that plugin permanently unremovable. **A disabled dependent does not veto either.** `disablePlugin` unregisters the tracking context and flips the state to `DISABLED` but never calls `pluginLoader.unloadPlugin`, so a disabled plugin is still in `getLoadedPlugins()` and would refuse on behalf of something that is not running. `blockingDependentsOf` therefore takes an `isDisabled` predicate with **no default**, so a new call site has to answer; it **fails closed**, since `PluginState` has more members than LOADED and DISABLED and treating "not exactly LOADED" as "not running" would drop real dependents. Refusals must stay visible. `PluginLoaderDelegateImpl.unloadPlugin` returns a bare `Boolean`, and `uninstallPlugin` logs `Uninstalling plugin` *before* it decides - so a refusal used to leave the log stopping mid-sequence with the manager's reasons dropped at the delegate boundary. It now logs `Plugin unload refused` with them, read off `PluginUnloadException.reasons` rather than the joined message. Refusal and failure log **separately**: `uninstallPlugin` returns `Result.failure` for both, and they send a reader to opposite places - a refusal means another plugin is in the way, while "Plugin not found" or a `pluginLoader.unloadPlugin` error is a fault that wants a stack trace. The plugin side cannot receive those reasons through a `Boolean`, so the Toolbox's message points at the host log rather than restating the failure. **Known, and deliberate: the veto does not distinguish Update from Remove.** The host's own update paths pass `force = true` (`PluginUpdateBridge`, `PluginStoreVersionBridge`), so they never meet it. The one path that does is the plugin-facing `PluginLoaderDelegateImpl.unloadPlugin`, which cannot tell which button was pressed - so a plugin with a *required* loaded dependent can still not be updated from the Toolbox, even though an update ends with it present again at a newer version. Refusing the removal is right; refusing the update is much harder to justify, and closing it needs an update-shaped verb (or an intent parameter) on the api rather than a change to this predicate. `PluginDependencyResolution.missingFor(manifest, installedIds)` now answers what is absent, and `MissingDependencyDialog` offers to install it. Four things about the placement: - **The wizard reports once after its whole batch, never per iteration.** A first-run selection of `[jupyter-notebook, ai-gateway]` - the pick this feature exists for - otherwise prompted for the gateway while the loop was two iterations from installing it, and taking Install raced the wizard's own download (the two paths write different filenames for one plugin id, so neither the path nor the coalescing key collides). After the loop, `installedAndOnDisk` already holds everything the batch installed, so nothing intra-batch is reported at all. - **`MissingDependencyReporter` is called from the three paths that install for a user**, and never from `DynamicPluginManager.installPlugin`. That manager method also serves startup restore, the bundled-plugin load and the api hot-swap's reload-all, so reporting there would be one dialog per plugin on every launch. The three are `PluginLoaderDelegateImpl.loadPlugin` (what plugin-manager's install and update flows reach), `PluginInstallService` (the first-run wizard, where several plugins are chosen at once and so the likeliest place for an unmet dependency) and `PluginUpdateBridge` (an update can add a dependency the installed version never declared). A **reload** must not report: `resetPluginInstances`, the Toolbox reload and the evolver's hot reload all end in a load, and none is a user asking for anything. **Re-enable is a user action in a way a reload is not.** `enablePlugin` and `handleAccessChange` (RBAC un-hide) never go through those three install reporters, and after #178 a required dependency can be removed while its dependent sits disabled. Both paths therefore raise the same prompt via `DynamicPluginManager.onPluginActivated`, wired by `PluginLoaderDelegateSetup` to `MissingDependencyReporter.report`. A redundant enable (already enabled) does not re-offer. `PluginAccessTransitions` reconciles the first access snapshot, login and account changes silently; only subsequent access changes for the same authenticated user can report. An authenticated user with no permissions still establishes a baseline, so their first real grant reports. Notification queues `reportPluginActivation` on the manager's scope, checks files on `Dispatchers.IO` outside the registration lock, and uses the captured manifest. The Enable caller never suspends on this advisory work before persisting its enabled flag; cancellation belongs to the manager lifecycle. Manifests without dependencies skip reporting. - **Optional dependencies are reported, flagged, not dropped.** An optional dependency is how a plugin says "this feature needs that plugin". Dropping them would leave this reporting nothing for the case it was built for. - **The dependency bus retains pending prompts until atomically claimed.** Each eligible window checks presence before claiming; cancellation during that check leaves the prompt pending. Installed or declined prompts are also claimed and retired, so a later explicit retry can reserve the key. A window waits for its current dialog to close before handling another prompt. - **Installing is the host's to do.** `PluginRepository.getPlugin(id)` plus `downloadPlugin` resolve an id to a jar, which no plugin can do - a plugin holding a null API can only send the user to the Toolbox to search by name. **The downloaded jar is vetted before it is loaded.** Nothing binds a store row to the plugin id its jar declares: `enforceStoreSignature` binds the hash to the row and the row to the store's key, not the identity, so an admin uploading the wrong jar is enough. Loading first and checking after is too late twice over - `installPlugin` inspects the *incoming* manifest and starts a full api hot swap for a newer `ai.rever.boss.plugin.api` jar, which is exactly what `NOT_USER_INSTALLABLE` exists to keep out of a two-button dialog, and a jar declaring some other installed plugin would be registered against a path that is about to be deleted. So the manifest is read first and a mismatch is refused; the post-load check stays as belt and braces. The recorded version comes from that manifest too, since update checking compares against it. Five things `StoreMissingDependencyInstaller` gets right that are easy to get wrong, all found in review: - **The download goes to a `.jar.part` sibling and is moved into place, with its sidecar.** `downloadPlugin` streams into whatever path it is given and `outputStream()` truncates on open, so downloading onto the final name destroys any jar already there the instant the connection opens - and a stream that then dies leaves a truncated file at a name every later launch tries to load. A "was a file already here" guard does not help: by then its contents are gone either way. The suffix deliberately does not end in `.jar`, so a part file left by a kill is ignored by the directory scan. Both files move together, because the signature is written next to the path `downloadPlugin` was given. - **Cleanup removes the `.sig` sidecar with the jar.** Reinstalling the same version reuses the filename, so a surviving sidecar meets fresh bytes and hard-fails the load, which is worse than being unsigned. - **It writes the `installed.json` entry.** `setPluginEnabled` updates an existing entry and does nothing when there is none, so a plugin known only by its presence on disk cannot be disabled persistently. - **Nothing is reported for a plugin that did not actually register.** `installPlugin` returns success with `state = DISABLED` when registration failed as binary-incompatible or the plugin is hidden for lack of access; reporting then offers to install a second plugin to support a dead one. All **three** report paths check `state == LOADED`, not just the delegate. - **A promotion that half-succeeds cleans up both paths.** If the jar moves and the sidecar step then throws, deleting only the part file would leave an unvetted, never-loaded jar at a scannable name for the next launch to load. Installs are detached and coalesced per plugin id (`KeyedDetachedJobs`, as reloads are): the prompt is driven from a window's scope, so closing that window mid-download would otherwise abort the install and leave the partial jar. Reporting is gated to the install entry point. `doReloadPlugin` finishes by calling `loadPlugin` too, and reload is reached by `resetPluginInstances`, the Toolbox's update flow and the evolver's hot reload - none of which is a user asking to install anything, and re-offering a dependency someone declined on every reload would be worse than silence. **Transitive dependencies are resolved in the consent dialog, before installation.** The dialog calls `MissingDependencyInstaller.planFor`, which on the store-backed installer walks the store's `dependencies` column through `PluginDependencyResolution.installPlan` - dependencies first, each plugin asked about once, cycles and a size cap tolerated and logged - and shows the extra ids as a scrollable "Also installs" list under the plugin id. Every id stays readable; do not ellipsize consent to additional installs. Install then runs `installAll` over that plan and stops at the first failure, leaving what came before it. The dependency still loads through the manager directly rather than `loadPlugin`, and for the same reason as before: answering one question must never produce a second dialog. What changed is that the first question now covers the whole closure, where it used to cover one plugin and stay silent about the rest. A plugin pack consents through `pack_plan` rather than this dialog, and the same rule holds: `pack_plan` resolves each install's closure with the same `planFor` and returns the extra ids as `alsoInstalls`, with `closureComplete: false` plus `unresolved`, `cyclic` or `truncated` when the walk could not see all of it. `pack_apply` installs the order its own fresh plan resolved and is handed that order rather than re-walking the store, so the installs cannot exceed the closure that apply's own plan resolved - a walk repeated at install time could follow store rows that changed in between. That bounds the install to the plan, not to what the operator saw: the `pack_apply` approval dialog shows the raw pack arguments, so a direct `pack_apply` with no earlier `pack_plan` has no closure in its consent. Showing the closure in that dialog is a recorded follow-up. Three properties of the plan are worth knowing before touching it. The plugin the user was asked about is always in the plan and always last, even if it turns out to be present, because the Install guard already answers that and an empty plan has no sensible reading. A store that cannot describe a plugin contributes null, and that plugin is attempted but not expanded. If it cannot install, the plan stops before its dependents, including the root; metadata failure is not proof that an unknown dependency is optional. Finally, `planFor` has a default of "the plugin alone" on the interface, because `PluginLoadGateRecovery` and `PluginStoreVersionBridge` install a plugin the user named and were never shown a closure to consent to; only the dialog asks for a plan. The entire accepted plan is detached from the window, so closing the observing window does not abandon later dependencies or the root. Different consent lists can run concurrently; ordering is guaranteed within each plan, not across windows that accepted different lists. Deliberately out of scope, so nobody assumes more than exists: - **Optionality is not followed across the store.** `PluginInfo.dependencies` is a list of ids; the `optional` flag lives in the jar's own manifest, which is not available before a download the user has not agreed to. Every transitive edge is therefore treated as required. A plugin whose jar declares a dependency its store row did not is logged, not prompted for and not installed - consent was for the list shown. - **A failed plan is not rolled back.** A dependency installed on its own is harmless and may already be wanted by something else; deleting it to tidy up a failure would be the one outcome worse than the failure. - **`PluginDependency.version` is ignored.** Presence is by id, matching `checkCanUnload`. A plugin needing 2.x is satisfied by 1.x, and a prompt could not usefully fix a wrong-version install anyway. - **`NOT_USER_INSTALLABLE` ids are never offered**: the microkernel runtime (which `loadPlugin` refuses outright and `DefaultPlugin` skips on scan, so it looks missing to every manifest naming it) and the api plugin (whose install is an unload-all / swap / reload-all hot swap, not something to start from a dialog about something else). - **Window routing is best-effort, not guaranteed delivery to the initiating window or manager.** A prompt can carry a preferred window id (`MissingDependencyPrompt.windowId`, resolved from focus at report time - it can differ from the window whose install actually found the missing dependency, and a null target is unscoped by design). Delivery itself is a claim registry, not a channel: `PluginDependencyBus.missingDependencies` broadcasts every pending prompt to every open window each time anything changes (or once a second, as a fallback for the one case nothing else wakes a collector for - the preferred window closing with no new report to trigger a rescan), and each window decides independently, via `shouldClaimMissingDependencyPrompt`, whether to `claim()` it. A non-target window that isn't the right audience simply leaves the prompt where it is; there is no reoffer, no retry loop, and no per-rejection wait. When the preferred window has closed, any other window may claim the prompt instead - and its installer preserves its reporting manager while it is live and re-resolves to another live manager after disposal (`MissingDependencyReporter.installerFor`). With none left, loading fails explicitly. Each collecting window owns its periodic rescan, including while the queue is empty. Closing a window after its dialog appears can still abandon that claimed prompt. - **The bus filters at report time, not only in the collector.** A prompt already declined this session is dropped before it is ever admitted; a duplicate report for a key still pending keeps the first reporter's prompt (and its `windowId`) rather than being replaced by the second. There is no buffer to overflow - `pending` is a map with no capacity ceiling - so this is about not re-asking a question already answered, not about conserving a scarce slot. - **A declined prompt is remembered for the session, not persisted, and keyed by kind.** "Not now" on an *optional* dependency is one answer about that plugin - three consumers declare the gateway optional, and being asked three times for one answer is what this prevents. "Skip" on a *required* one is keyed by `(dependent, missing)`, because another plugin that hard-requires the same thing is a different question and silencing it would be worse, not better. Neither persists: an answer that outlived the session would leave no way to be asked again short of editing a file. - **The dependent is not reloaded after its dependency installs.** It has already loaded and already resolved its handle to the dependency, typically to null. This is survivable because the consumers resolve the API *lazily, per call* - which their own AGENTS.md files require, precisely because plugin load order is not guaranteed - so they pick it up without a reload. A consumer that cached the handle at `register()` would stay broken until relaunch, and would be wrong for the same reason on a normal cold start. **Both halves must share one definition of "installed".** The reporter filters the manifest's dependencies against what is present; the installer's Install guard asks the same question. When those disagreed - reporter on the raw `pluginStates` keys, installer on keys-plus-jar - a failed install left a dangling entry that made every *later* dependent of that plugin report nothing at all, silently re-creating the problem this feature exists to remove. There is now one `installedAndOnDisk` predicate in `PluginLoaderDelegateImpl`, passed to both. **"Installed" is `state == LOADED || (the jar exists && not recorded incompatible)`.** Both halves are load-bearing and each was a bug on its own. Requiring only an entry meant a binary-incompatible load - which registers a DISABLED entry while the installer deletes the jar it rejected - looked installed, so Retry reported success with nothing installed and every later dependent went silent. Requiring only the jar meant a *running* plugin whose file had moved looked absent: `PluginJarReconciler` and the updater both rewrite paths without repointing the manager's in-memory `jarPath`, so the prompt would fire for something already loaded and Install would fail with "Plugin already loaded". The incompatibility clause exists because there are **two** binary-incompatibility paths in `installPlugin` and only one of them fails: the load-time one returns `Result.failure`, but the *registration-time* one force-unloads the plugin and returns `Result.success` with `state = DISABLED` and the jar still on disk. Without it, that jar made a plugin which was unloaded and will not run count as installed - so Install reported success and wrote an `installed.json` entry for it, and every other dependent of it went unprompted. One trap worth knowing: `isInstalled` has to mean *usable*, not "the manager has an entry". `installPlugin` registers a DISABLED entry for a binary-incompatible plugin, and this installer deletes the jar it rejected - so an entry-only check made Retry close the dialog reporting success with nothing installed, and silenced the prompt for every other dependent of that plugin. The check is an entry whose `jarPath` still exists. ### A settings section can offer to install the plugin that serves it Two sections render a panel that belongs to a plugin - `Editor` and `Language servers`, both editor-tab. (`Settings > AI Providers` was a third, served by secret-manager, until that section moved into the plugin's own panel; Settings search reaches it through a **panel signpost** now, and that entry is filtered on the panel being registered rather than explaining its absence.) Both used to say "isn't loaded yet" for every reason there was no panel, which is true of exactly one of them. A plugin that was never installed, or that the user switched off, does not arrive however long they look at it. `PluginSettingsUnavailableNotice` now tells the four apart and offers an Install button for the one case where that is the answer. It leans on `MissingPluginOffer`, so no second install path exists: the press raises the host's own `MissingDependencyDialog`, which names the plugin **from the store** and shows the id it will install by, rather than installing on the button's say-so. **`PluginState.DISABLED` is not one state, and that is why the decision is a pure function.** `DynamicPluginManager` writes it for at least three unrelated reasons - `disablePlugin`, a registration that failed as binary incompatible (`:1118`), and a plugin hidden because the user lacks access (`:1120`, `:2293`) - and "enable it in the Toolbox" is true for only the first. So `pluginSectionAbsence` takes the crash registry's `isIncompatible` and the manager's `getInaccessiblePlugins()` alongside the state, and its **order is load-bearing twice**: - permissions are asked **first**, because an inaccessible plugin is recorded DISABLED too, and telling that user to switch it on points them at a row that is not listed for them; - `isIncompatible` and DISABLED are asked **before** `installed`, because `MissingPluginOffer.isInstalled` counts both as installed (the jar is on disk, which `MissingDependencyInstaller` documents deliberately) - so the other order offers an Install button to someone whose problem a download cannot fix. That exact bug has shipped here once already with the bookmarks shelf. All three orderings are mutation-verified: reversing each one fails a named test. **The section supplies exactly one fact the manager cannot answer**, `servesNoPanel` - the API is registered but this version has no panel for that section. Without it a loaded plugin reported "isn't loaded yet" forever. Everything else, permissions included, is derived from the plugin id, so a new section gets the whole behaviour by naming its plugin. An earlier version took the permissions as a parameter and only one of the then-three sections passed it, which left the other two telling a user who cannot access the plugin to go and switch it on. **The notice gates on `MissingPluginOffer.isInstalled`, not on "can I reach the API".** Those are different questions and they disagree exactly when the plugin is installed but not running - which is the state where a button that appears does nothing. Same rule the tab bar's bookmarks shelf states. `null` from `isInstalled` means the question cannot be answered here (no active manager, no injected installer factory), and it must not become an offer: the section falls back to the neutral wait. **Known: the consent dialog opens over the main window, not the Settings window.** `SettingsWindow` is composed inside the main window's subtree and opts *its own* dialogs out of heavyweight overlay routing precisely so they do not open centred on the main window - but the dependency dialog is raised through `PluginDependencyEventBus` and composed by `BossAppDialogs`, outside that opt-out. It is always-on-top so it is not lost, just not where the press happened. The prompt now carries a best-effort BossWindow id, but Settings is not a registered BossWindow with its own dependency collector. Routing among BossWindows does not change this Settings placement limitation. **A raised offer is not a shown dialog.** `PluginDependencyEventBus.report` drops silently when a prompt for that plugin is already queued, so `offerIfMissing` returning true is not proof anything appeared. The press therefore logs whatever happens, matching the bookmarks shelf's call site. ## The tools menu shows a scrollbar only when it scrolls `ToolLauncherDialog`'s grid is capped at `VISIBLE_ROWS`, and its scrollbar is pinned visible while there are more rows than that. It reads as one pattern with the Top of Mind plugin's Space picker, which does the same job and now uses the same tile vocabulary and the same 0.7 alpha. Two traps, both hit while wiring this: - **`Modifier.scrollbar` has no fits-the-viewport guard**, unlike `lazyListScrollbar`, which refuses to draw. Given content that fits it computes `contentLength == viewport` and a FULL-LENGTH thumb, so pinning `alpha` unconditionally paints a permanent bar down a grid with nothing to scroll. Anything pinning that alpha has to gate it. - **The gate cannot be a scroll-state read.** `ScrollState.maxValue` starts at `Int.MAX_VALUE` and holds it until the scrollable has measured, so `maxValue > 0` is true on every first composition; `canScrollForward` is `value < maxValue`, so it is true then as well. Both were tried and both drew a bar under three tiles - a probe printed 2147483647. Gate on layout arithmetic instead, which is right on the first frame: here, the row count against `VISIBLE_ROWS`, rounded UP so 13 tools in 4 columns is 4 rows. ## Retiring a plugin into another one `RetiredPlugins.sweep()` uninstalls a plugin whose job another plugin has taken over. It runs at step 3c of `PluginStoreSetup.loadPersistedPlugins` - after `PluginJarReconciler`, so `installed.json`'s `jarPath` is trustworthy, and before step 4 reads it, so a retired plugin never registers a panel or an MCP tool on a machine where its replacement is present. It is `internal` and **startup-only**: `PluginArtifactCleanup.remove` deletes the jar without unloading anything, which is safe only before any plugin has loaded (`PluginRemoval` spells out the alternative - `NoClassDefFoundError` from code that is still running). There is no "already done" flag. The sweep keys on the plugin being installed, and removing the `installed.json` row is what makes the next launch a no-op. **It fails closed at every step, and each step is a bug someone would otherwise ship.** A wrong "yes" deletes a panel the user still needs, so all of these mean "not yet, wait for a launch that can prove it": | Refusal | Why | |---|---| | replacement not installed | the user would have no panel at all for what the retired one did | | replacement disabled | `setPluginEnabled` and a for-lack-of-access install both leave the row and the jar in place | | replacement's jar missing | `installPlugin` records a DISABLED entry for a plugin it then rejected and deleted | | replacement's version unparseable or absent | see below | | replacement older than `minReplacementVersion` | that version has not absorbed the retired plugin's work yet | | the retired plugin would be restored anyway | a bundled or system plugin is re-copied at step 1 or re-downloaded at step 2, so removing it is a copy-then-delete loop on every launch, notice included | | its jars could not actually be removed | see below - the row must not outlive the file | **The row is dropped only once the jars are gone, and that ordering is the whole guard.** `PluginArtifactCleanup.remove` drops the `installed.json` row unconditionally and only *logs* whether the delete worked, which is right for the interactive uninstall (the Toolbox already deleted the file) and wrong here. `DefaultPlugin.loadExternalPlugins` scans the same plugin directory after the persisted pass, **in the same launch**, and installs every jar the manager does not know about. So a retirement that drops the row while a jar survives does not leak a file - it reinstalls the plugin in the session the user was just told it left, `PluginBuildProbe` writes a fresh row on load, and every launch after that sweeps and announces again. The copy-then-delete loop the bundled/system veto prevents, by another door. `purgeJarsFor` therefore deletes **by manifest id, not by the recorded path**: `jarPath` can be stale while a differently named jar for the same plugin is still in the directory, and artifact prefixes are prefixes of each other (`boss-plugin-terminal` matches `boss-plugin-terminal-tab-*.jar`). It re-lists rather than trusting the delete results, because a Windows lock makes `delete()` return false silently and an already-absent file returns false too - absence is the postcondition, not the delete count. **`satisfiesVersionFloor` cannot be used alone here.** It returns `true` for anything `SemanticVersion.parse` rejects - `dev`, `v1.2.17`, `1.2.x`, a trailing `-` or `+` - by design, because for gating an *update* an ungated update beats a wrongly gated one. The consequence here runs the other way, and a locally built or side-loaded jar whose manifest version is not strict semver is exactly the case that would delete the user's only secrets panel. So the version is parsed explicitly first; `plugin-dependency` is on composeApp's classpath for that one call (`plugin-updater` has it as `implementation`, so it is not transitive). **The restore veto is asked at the call site, not inside `RetiredPlugins`**, which keeps that object free of both the manager and the system-plugin service. It combines `PluginRemoval.removalVeto` (the bundled dir) with `PluginStoreSetup.systemPlugins` (the remote `system_plugins` table merged over the built-in fallback). `ALL` is a list someone will append to without reading the change that added it, so this is a runtime guard rather than a comment. **Retirement is also a filter on what is *offered*, not only on what is installed.** Unlisting the store row is a manual database action outside this repo and outside CI, and until it happens the store keeps returning the plugin - so a user can install it, have the sweep remove it at the next launch, and install it again indefinitely. `RetiredPluginIds` (commonMain, because the wizard is desktopMain and the home tool grid is commonMain) is filtered wherever `PluginDependencyResolution.NOT_USER_INSTALLABLE` is, and `RetiredPluginsTest` pins it against `RetiredPlugins.ALL` so the two lists cannot drift. **One notice per sweep, not per retirement.** `StatusMessageManager.showMessage` cancels the previous message, so announcing in the loop would show only the last removal - and the sweep is one-shot, so a missed notice means a panel vanished with no explanation ever. Each retirement's removal is also wrapped individually, so one failure cannot drop the rest or lose the ids already removed. `noticeFor` **groups by replacement**: the function only exists for the multi-removal case, which is exactly the case where two retirements can point at different replacements, and taking `first().replacementDisplayName` told the user their panel moved somewhere it did not. ## Configuration ### Shared managed AI providers `supabase/functions/boss-ai` serves multiple configured models through one authenticated endpoint. Configuration and accounting tables are service-role-only; every inference rechecks live per-model permissions and atomically reserves usage. Secret Manager owns BOSS AI authentication and discovery. It requests a single-use ticket through the existing authenticated RPC API and exchanges it with the edge function. The database derives identity from auth.uid(); only the service role can redeem tickets. No BOSS AI-specific desktop host registration or shared vault definition is required. See `supabase/functions/boss-ai/README.md` for deployment and accounting semantics. Do not put upstream credentials in the shared definition or accept caller-selected broker URLs. Plugin bundling is intentionally separate. Create `local.properties`: ```properties jxbrowser.license.key= SUPABASE_URL=https://api.risaboss.com SUPABASE_ANON_KEY= SUPABASE_FUNCTION_URL=https://api.risaboss.com/functions/v1 GITHUB_TOKEN=ghp_your_token_here # Optional, 60 req/hr without MACOS_DEVELOPER_ID=Developer ID Application: ... # Optional, signs local packaging ``` **Priority**: Environment variables > System properties > local.properties > Embedded build config. For `BOSS_MODE` only, the `env_vars` file is consulted between system properties and local.properties. The reader and the settings writers use `BossDirectories.resolve("env_vars")`; `BOSS_DATA_DIR` does not redirect this preferences file. The file uses the plain, unquoted `KEY=value` format; both in-repo readers also accept a shell-style `export KEY=value` prefix (`env_vars` doubles as the secret-manager plugin's key file) via one shared normalization, `EnvVarsFormat` in `ai.rever.boss.config` - the two readers must never parse it differently. Other keys in that file cannot override host configuration. `BOSS_MODE` is normalized to trimmed uppercase by ConfigLoader; runtime plugin gates must use that resolver rather than prefixing a raw getenv lookup. Nonblank environment or system property values override the saved file at every resolution tier. The ConfigLoader snapshot reads the file once per process, so a mode change applies on the next launch; the settings surfaces share one published save state within the running process. Atomic preference writes preserve existing POSIX permissions. ### Credential brokers A provider whose credential nobody types in: the user is signed in to BOSS and an organisation gateway mints a short-lived scoped key for that identity. RISA Codex GLM works this way. The exchange is **host-side and stays that way**. Nothing on `PluginContext` exposes the Supabase access token - `AuthDataProvider` gives identity only, `SupabaseDataProvider` proxies queries with the host attaching auth - so `CredentialBrokerClient` hands a plugin the *downstream* credential and never the session that bought it. **The gateway itself lives in `risa-labs-inc/risa-llm-gateway`** (private), not here. It was added to this repo in PR #136 alongside the desktop client and moved out with its history: it ships in no BOSS release, deploys on its own Cloud Build, and this repo is public, which made every infra and authorization change a public one. What stays here is the app's half of the contract - `RisaLlmTokenCommand`, `CredentialBrokers`, and the `LLM_TOKEN` verb on the single-instance channel. What the two sides owe each other is written down in `docs/llm-gateway-contract.md`, because nothing links the repositories at compile time or in CI any more. **A broker is named by id, never by URL.** `CredentialBrokers` owns the id to endpoint map. An `exchange(url)` shape would have handed every installed plugin a way to post the user's session token to a host of its choosing; as it is, the worst a plugin can do is name a broker this build does not have. `CredentialBrokersTest` pins that, plus that every broker declares an https endpoint and a `scopedTo` prefix (published so a careful plugin can check where it is about to send a bearer token). `RisaLlmTokenCommand` is a thin adapter over the same registry, so the `BOSS llm-token` helper Codex invokes and the plugin-facing `BrokeredCredentialProvider` cannot drift apart or hold two copies of the endpoint. Adding one means an entry in `CredentialBrokers.all()`. The plugin-facing side needs no change. **The three-wrapper rule applies.** `brokeredCredentialProvider` is overridden in `DefaultPlugin`, `TrackingPluginContext` **and** `plugin-platform/plugin-sandbox/.../SandboxedPluginContext`. Missing the sandbox one returns null for every plugin, silently - that has happened before with `mcpToolRegistry`. The implementation lives in `desktopMain` (it speaks HTTP), so `DefaultPlugin` reads it through `BrokeredCredentialAccess`, a commonMain holder that `main.kt` populates at startup. It happened a second time with `registerSearchProvider`: neither wrapper forwarded it, so no plugin's search provider ever reached global search. `PluginContextWrapperForwardingTest` now fails when either wrapper does not declare a `PluginContext` member. It matches by name, so it cannot tell a wrong forward from a right one, and a third wrapper has to be added to it. ### AI credentials are not configured here `OPENAI_API_KEY` used to be listed above as a `local.properties` key that enabled AI self-healing. Nothing has ever read it from that file - it is an **environment variable**, and the priority order above does not apply to it. - **AI providers** (chat, agents, plugin AI features) are owned entirely by the **secret-manager** plugin, in the **AI section of its own panel**. The host has no provider list and no settings section for one; it relays the plugin's through `PluginContext.llmProvider`. See that plugin's `AGENTS.md`. The host used to render `Settings > AI Providers` from that plugin, through a `LlmProviderAPIAccess` singleton. Both are gone: the credentials live in that panel's vault, so the page that manages them belongs beside them rather than two clicks away in another window, and the singleton existed only to give host composables a plugin handle. What remains is `DefaultPlugin.llmProvider`, which resolves against **its own** instance's registry - deliberately never through a singleton, because `DefaultPlugin` is per window and a shared cached reference would hand window 1's plugins whatever window 2 registered. A stale `boss://settings?section=LLM_PROVIDERS` deep link now resolves to `SettingsDeepLink.Unresolved`, so the window opens on its default section rather than failing. Settings search still answers for `api key`, `anthropic`, `claude` and the rest: a curated **panel signpost** (`panelSignpost` in `SettingsSearchEntries.kt`) opens the Secret Manager panel and raises the main window. It is the only search entry that navigates out of the Settings window. The delegated-section keywords could not have covered this - a panel is not a settings page, so nothing merges it into the index at query time. **There is deliberately no version floor on secret-manager.** The AI section exists in that plugin only from 1.2.19, and nothing in the host gates on it: secret-manager is not in the `system_plugins` manifest, so no `min_version` applies, and plugin updates surface in the Toolbox rather than installing themselves. A user on 1.2.18 who takes this host build gets the Secret Manager panel with no AI section in it. That is accepted rather than overlooked, and it is a weaker case than `RetiredPlugins.minReplacementVersion`, which names a release because getting it wrong **deletes** the user's only secrets panel. Here nothing is deleted and nothing is lost - the credentials stay in the vault, `PluginContext.llmProvider` keeps serving them to every plugin that asks, and updating the plugin restores the page. The floor would have to be enforced somewhere, and the only mechanism the host has for that is refusing to load the plugin, which would take the vault down with it. - **AI self-healing / repair** is the one credential the host still resolves itself, because `SelfHealingSettingsManager` runs before any window or plugin exists and so cannot reach the plugin's store. It reads `AI_REPAIR_API_KEY`, then the provider's own variable (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / …), then the legacy `~/.boss/llm_settings.json` and its `.migrated` sibling - all as **env vars / files, never local.properties**. A key rotated in the Secret Manager panel's AI section does not reach it. There are NO credential fallbacks in source (public repo). Packaged builds get the JxBrowser license and Supabase settings baked in by the `generateEmbeddedConfig` Gradle task, which reads env vars (CI secrets) or local.properties at build time and emits a git-ignored classpath resource. ### `BOSS_SESSION_IMPORT`: handing a session to a headless BOSS A headless BOSS (one container per Optimist user, a Hunt agent seat) has nobody to sign in at its window. The host signs the user in elsewhere and drops that sign-in's refresh token in a file; BOSS adopts it as its own session (`refreshSession` then `importSession`). Code: `SessionFileImporter` and `DesktopSessionFileImport` in `desktopMain/.../services/auth`. - **The variable** names an absolute path. Unset, blank or relative means the hook is off and nothing changes. It is read with `System.getenv` only, deliberately **not** through `ConfigLoader`, so `local.properties`, system properties and `env_vars` cannot turn it on. Keep it that way: whoever controls this path controls which account BOSS is signed in as. - **When it acts**: only while there is no live session, i.e. `NotAuthenticated`, or `RefreshFailure` with an expired session (a container restarted after its session lapsed). It polls every 500 ms, slowing to every 2 s after a minute with no file, and stops on sign-in. - **Format**: exactly `{"refresh_token":""}`, UTF-8, at most 16 KiB. Any other key (even a harmless one such as `expires_at`) makes the file malformed. - **Checks**: a regular file, not a symlink (read with `NOFOLLOW_LINKS`); owned by the process's numeric UID (`unix:uid`, compared with `/proc/self`; on macOS, where there is no `/proc`, by owner name); mode exactly `0600`; the parent directory owned by the same UID and not group or world writable. Only the immediate parent is checked, so pick a directory whose whole path is private. - **Stability**: the file is read only when its inode, size and mtime are unchanged across two consecutive polls and its mtime is at least one poll interval old, so a write still in progress is never read. A writer stalled mid-write for longer than that can still lose the token, which is why the contract below renames into place. - **Single use**: a file that passes is deleted before any network call, and a token that cannot be deleted is never presented. It is consumed even if the import then fails (invalid JSON, auth error); the host must write a new one to retry. Nothing read from the file is ever logged. - **A refused file is left in place** and the reason logged once (`Session import file refused and left in place`, with e.g. `mode is not 0600` or `is owned by uid 65534, not the process uid 1000`). Fixing the file (or replacing it) is picked up on a later poll. A refusal that repeats slows the poll like an absent file. - **Session recovery**: `CoreAuthService` does not clear a session that replaced the one it was recovering (`SessionRecoveryPolicy.replacedSince`), so a handoff that lands while recovery's refresh of the old token is in flight survives that refresh's rejection. The guard is one-directional: a rejection that lands before the handoff's `importSession` still clears the old session, and the import then lands on top of it. **Writer contract.** Create the file owner-only and rename it into place, in the same directory: ```sh umask 077 printf '{"refresh_token":"%s"}' "$rt" > "$BOSS_SESSION_IMPORT.tmp" mv -f "$BOSS_SESSION_IMPORT.tmp" "$BOSS_SESSION_IMPORT" # rename(2), atomic ``` The importer only looks at the exact path, so the `.tmp` sibling is never picked up. A writer that cannot rename still works if it creates the file `0600` from the start and finishes within one poll; `printf > f && chmod 600 f` is refused until the `chmod` lands, then imported. **Where to put it**: a private tmpfs, so the token never reaches persistent disk or an image layer. `$XDG_RUNTIME_DIR` (`/run/user/$UID`, 0700) on a systemd host; in a container, a dedicated mount such as `--tmpfs /run/boss:mode=0700,uid=` with `BOSS_SESSION_IMPORT=/run/boss/session.json`. `/tmp` itself (1777) is refused. A bind mount under rootless Docker or a user namespace can show the file as `nobody`/65534; that is refused with the UID mismatch in the log. ### Supabase Deployment ```bash supabase functions deploy --project-ref pcnwqamqdnsadranufjv --no-verify-jwt supabase link --project-ref pcnwqamqdnsadranufjv # First time ``` ### fluck.risaboss.com (`fluck-web`) A sign-in page for the owner's Fluck web chats. The Fluck plugin heartbeats `fluck_web_upsert_instance` (as the user, every 30 s) into `public.fluck_web_instances`; the `fluck-web` function signs the user in (Google, Apple, magic link with `create_user: false`), lists `fluck_web_list_instances()`, and on Open mints a 60 s single-use ticket with `fluck_web_mint_ticket` and embeds `/#/t/` in a full-viewport iframe (`#fluckframe`, as `live-sessions` embeds its viewer), so the address bar stays on fluck.risaboss.com. The Fluck redeems the ticket with `fluck_web_consume_ticket` as its own signed-in user, so a ticket minted by another account can never be redeemed; it keeps its session token in the frame's sessionStorage (no third-party cookie) and allows framing only by `https://fluck.risaboss.com`. Opening from the list pushes `?instance=` (never the ticket): browser Back closes the frame, and a reload reopens that Fluck with a fresh ticket; switch and sign-out drop `?instance`. A load that may auto-open (`?instance`, or exactly one Fluck online, which opens at once with no countdown) starts in `body.launching`: a copy of the web chat's own boot screen (the name, centred, its `--bg`/`--text-3`, system font), so handing over to the frame changes no pixel; no portal chrome until the frame is up or the page has to ask. The frame posts `{type:"fluck-signed-out"}` (close, reload the list), `{type:"fluck-switch"}` (close, show the list) and `{type:"fluck-title", title}` (sets `document.title`, capped, text only); the page accepts them only from the frame's window and the endpoint's origin and ignores anything else. Backwards compatibility: a framing-capable Fluck first posts `{type:"fluck-hello"}` (before redeeming the ticket); with no hello within 8 s the page assumes an older Fluck that refuses framing, closes the frame and navigates top-level to the same URL (replacing its entry with `?list=1` first), so the portal can ship before every Fluck frames. CSP adds `frame-src https:`; the portal itself keeps `frame-ancestors 'none'`. No message content is stored. Security follows `live-sessions` (nonce CSP, no CORS, `__Secure-` cookies, cross-site refusal, token rotation), plus an exact-Origin and CSRF-nonce check on `POST /api/open`. Endpoints must be bare https origins (a table CHECK and a second check in the function). A Cloudflare Worker (`infra/cloudflare/fluck-web-alias`) proxies the vanity host onto the function. Deploy is manual. Nothing below runs in CI: ```bash supabase link --project-ref pcnwqamqdnsadranufjv # once supabase db push # 20261006120000_fluck_web_instances.sql ALIAS_SECRET=$(openssl rand -hex 32) # shared by the function and the Worker supabase secrets set --project-ref pcnwqamqdnsadranufjv \ FLUCK_WEB_PUBLIC_BASE_URL=https://fluck.risaboss.com FLUCK_WEB_PUBLIC_BASE_PATH=/ \ FLUCK_WEB_AUTH_PUBLIC_URL=https://api.risaboss.com FLUCK_WEB_ALIAS_SECRET=$ALIAS_SECRET supabase functions deploy fluck-web --project-ref pcnwqamqdnsadranufjv --no-verify-jwt supabase functions deploy redirect --project-ref pcnwqamqdnsadranufjv --no-verify-jwt # Fluck web arm cd infra/cloudflare/fluck-web-alias && npx wrangler login echo "$ALIAS_SECRET" | npx wrangler secret put FLUCK_WEB_ALIAS_SECRET && npx wrangler deploy curl -sf https://fluck.risaboss.com/ -o /dev/null -w '%{http_code}\n' # 200; 503 alias_unverified = secrets differ ``` The function trusts the Worker (serves the page, keys rate limits on the visitor's IP) only when `X-Fluck-Web-Alias-Secret` matches `FLUCK_WEB_ALIAS_SECRET` (32+ chars). Direct hits on `api.risaboss.com/functions/v1/fluck-web/` are always redirected to the vanity host. Dashboard, by hand: Auth -> URL Configuration -> Redirect URLs, add `https://fluck.risaboss.com/auth`; Auth -> Email Templates -> Magic Link, paste `supabase/templates/email/magic-link.html` (it gained a `$fluck` branch). Without the redirect URL GoTrue silently falls back to `boss://auth/verify`. Tests: `supabase/tests/fluck_web_instances_test.sql` (pgTAP, `supabase test db`) and `cd supabase/functions/fluck-web && deno test --allow-all`. ### Decoding Supabase payloads Two rules in `ai.rever.boss.services.supabase`, both enforced by `SupabaseWiringTest`: **Decode through `supabaseJson`, never the `Json` default.** The database is migrated ahead of installed builds, so a client will meet columns it does not model. Strict decoding treats those as a hard error, and since these RPCs return lists, one new column throws and takes the whole page with it. That is not theoretical - the organisation migration extended four secret RPCs and emptied the secret panels on every installed build at once. Note the wildcard `kotlinx.serialization.json.*` import in these files keeps `Json.Default` in scope, so the broken thing is what you get by not thinking about it. The decoder ignores extra keys and coerces nulls/unknown enums only for properties with defaults. This can hide genuine server bugs; required properties without defaults still fail. Continue to **declare every projected column `T? = null`** except the key; this model convention is upheld by review, and not all existing models follow it yet. Only the unpaginated `getSecretShares` list recovers individual malformed rows. It logs counts without payloads and fails if a nonempty response has no decodable rows. Paginated secret lists stay atomic because the pinned plugin API has no raw next-offset field and clients advance by returned `data.size`. Role/permission lists also stay atomic because authorization must distinguish an incomplete response from a valid denial. **Log `sanitizeSupabaseFailure(op, e)`, never the raw exception.** kotlinx appends the whole offending document to a malformed-input error, and these bodies carry passwords the server has already decrypted, recovery codes, and JWT claim sets. The request direction counts too: `SupabaseDataProviderImpl.rpc` parses caller-supplied parameters, and a plugin calling `create_secret` puts the new password in them. ## Microkernel Mode's toggle is applied on restart, not in the running process `Settings > Advanced` and the application menu both let an operator turn Microkernel Mode on, persisted to `~/.boss/env_vars` as `BOSS_MODE=KERNEL` by `MicrokernelModePreference` (disabling writes the line back as `# BOSS_MODE=KERNEL`). `env_vars` is also where the secret-manager plugin resolves API keys from; the mode line is the only key this area of the host manages. Because `ConfigLoader` reads `env_vars` as a `BOSS_MODE`-only precedence tier (between system properties and `local.properties`), a saved `BOSS_MODE=KERNEL` changes the mode the **next** launch runs in: `main.kt` starts `KernelBootstrap` when `ConfigLoader.getConfig("BOSS_MODE")` resolves `KERNEL`, and that snapshot is taken once per process. The toggle still activates nothing in the running process, nothing re-reads the file mid-launch, and a nonblank `BOSS_MODE` environment variable or system property overrides the saved file at every resolution tier. Disabling is one asymmetric step: the commented-out line contributes nothing to any tier, so resolution falls through to `local.properties`/embedded, which emit no `BOSS_MODE` in this repo - the effective mode is MONOLITH, matching the toggle. **The "restart required" comparand must be a latched startup snapshot, never a live read.** `MicrokernelModePreference.startupEnabledLatched` is set once, from the first `refresh()` a process makes, and never moved again - "what `env_vars` said when this process started". A live file read would clear the notice the moment the save lands, while the process still runs the old mode; a live `ConfigLoader.getConfig("BOSS_MODE")` would additionally reflect environment or system-property overrides the saved file has nothing to do with. The latch is the only comparand that answers "does this need a restart" for the saved preference; reinstating a live read here is the regression behind BossConsole#472's review. What this section covers: preference persistence, its readers, and the restart notice. Whether a launch actually starts in KERNEL mode, and whether that mode works (spawn, supervision, IPC), is #391's territory. - Use Compose Multiplatform Resource API (not Android resources) - Location: `composeApp/src/commonMain/composeResources/` - Use `BossLogger` for logging (not `println()` or `printStackTrace()`) - All Kotlin files must end with newline - Formatting is gated by ktlint (`./gradlew ktlintCheck`; fix with `./gradlew ktlintFormat`). Static analysis is gated by detekt with per-module baselines. - **Blame**: the tree-wide ktlint reformat is listed in `.git-blame-ignore-revs`; run `git config blame.ignoreRevsFile .git-blame-ignore-revs` once per clone so `git blame` skips it. ### Prose style: no em-dashes Write a spaced hyphen (` - `), never an em-dash (U+2014), in anything a person reads: docs, READMEs, release notes, commit messages, PR descriptions, UI labels and log lines. Existing code **comments** are out of scope and were deliberately left alone, so do not sweep them. (This paragraph names the character by codepoint rather than printing it, because the guard below would otherwise flag the rule that defines it.) What is actually gated, so nobody assumes more than exists: | Surface | Enforcement | |---|---| | `*.md`, `*.html` | `build.yml` fails a PR that **adds** one (added lines only, so legacy prose never blocks the build) | | the generated release-notes table | `test-release-download-table.sh` asserts its output has none, both the stable and pre-release copy | | string literals in code | **convention, upheld by review.** A diff cannot tell a comment from a literal, so `*.kt` is out of the guard | | code comments | out of scope entirely | The guard matches U+2014 only. En-dashes stay: they are used deliberately for ranges and arrows. The rule exists because generated text is the main source. `release-notes.yml` has Claude write every release's notes, and 343 of this repo's 549 doc em-dashes came from `docs/release-notes/`. ## Logging ### Resource cleanup Use `ai.rever.boss.utils.CleanupRunner` for independent synchronous teardown actions when one plugin callback or native release failure could skip remaining cleanup. Claim resource ownership before callbacks, preserve release order and the owning thread, and keep native browser drains before releasing their profiles. The helper logs each failed action and continues; it does not schedule cleanup or make an owner idempotent. Its `destroyLifecycle` method advances after a failed pause/stop only when Essenty changed state, and never redelivers a destroyed lifecycle. Keep startup, normal actions, and cancellation from suspended operations outside this guard. Simple dialogs with only Compose state need no extra cleanup wrapper. Use structured logging via `BossLogger` (SLF4J backend): ```kotlin private val logger = BossLogger.forComponent("MyComponent") logger.info(LogCategory.AUTH, "User signed in", mapOf("email" to LogSanitizer.maskEmail(email))) logger.error(LogCategory.NETWORK, "Request failed", error = exception) ``` **Categories**: AUTH, PASSKEY, BROWSER, TERMINAL, NETWORK, UI, SYSTEM, EDITOR, FILE, WORKSPACE, GENERAL **Security**: Always use `LogSanitizer` for sensitive data: - `maskEmail()`, `maskToken()`, `maskCredentialId()`, `maskUserId()`, `maskUriParams()` - A local file that fails to decode: log `decodeFailure(e)` as the data, never the decoder's message by any route (`error = e`, `e.message`, `e.toString()`). kotlinx puts the file's content in the exception message, and `decodeFailure` keeps only the exception type, the offset and the JSON path, with map keys masked. Catch `SerializationException` before `Exception` or `IllegalArgumentException`, which it extends. Supabase payloads use `sanitizeSupabaseFailure` (see below). This is the rule for new and converted call sites; older decode sites that still log the exception are tracked in #1711. **Config**: Set `BOSS_LOG_LEVEL` env var or `boss.log.level` system property (TRACE/DEBUG/INFO/WARN/ERROR) **Log file**: off unless asked for. `BOSS_LOG_FILE=/path/to/boss.log` (or `boss.log.file`) turns on a size-rotated file (10 MB, five backups) that receives entries at `BOSS_LOG_FILE_LEVEL` (or `boss.log.file.level`) and above, default ERROR. `BOSS_LOG_FILE=off` or a level of `OFF` disables it even if a default is ever switched on. The file threshold is applied after the console level, so it can only narrow: with the console at INFO and the file at DEBUG, the file gets INFO. Blank is unset at every step, an unrecognised level falls through to the next source rather than to INFO. File and console receive the same entries; callers must use `LogSanitizer` before logging sensitive data, since `BossLogger` does not sanitize them. `BossLogger.configureFromEnvironment()` in `main.kt` is the only host entry point; `configure()` has no host caller. Flipping `FILE_LOGGING_ON_BY_DEFAULT` in `BossLogger` makes it default-on at `~/.boss/logs/boss.log` for every install; that switch is deliberately one constant, because whether to default on was raised on #394 and is a policy call. ## Browser native disposal `BrowserHandleImpl.dispose()` invalidates the handle and detaches its UI, then `BrowserNativeDisposal` closes the browser only after its owned renderer-call executors drain. Direct plugin disposal and host window teardown share this boundary. Never replace the drain with a fixed timeout followed by `browser.close()`: cancelling a caller's coroutine does not stop a JxBrowser round trip. `DrainingBrowserExecutor` signals actual executor termination, including failed calls and cancelled queued jobs. Waiting suspends in a host-owned scope without parking another thread. Keep `executeJavaScript` on `BoundedBrowserCall`: the JxBrowser async Consumer overload does not invoke its consumer on RPC error, so that callback alone cannot settle a native-operation count. Profile release must follow `awaitNativeDisposal`, through `disposeBrowserResources`. Its cleanup outlives cancellation of the caller. Both service entry points return without awaiting native close. A drain pending after ten seconds warns once with the handle id, then continues waiting safely. A genuinely wedged call retains its browser/profile until it returns or engine recovery releases it; native-close failure retains the potentially live profile and is logged. Keep its fence and `inUse` protection: dropping both would let a new browser reuse it or LRU eviction delete it. Named-profile creation/seeding waits at most ten seconds to acquire the fence, then reports that it is still in use rather than suspending indefinitely. The process-wide cleanup scopes use daemon threads. The shutdown hook does not drain them before forced engine close/process exit, so pending native close and profile cleanup can be abandoned at exit. Ephemeral leftovers are reclaimed on the next managed-profile creation. This is not a guaranteed shutdown flush. This is not an engine-abort mechanism and does not coordinate external raw-JxBrowser callers or engine-level forced closure. ## JxBrowser's native libraries swap the process's malloc zones when they load On macOS the FIRST of JxBrowser's JNI libraries to load (normally `libtoolkit`) makes PartitionAlloc the default malloc zone in a static initializer, briefly unregistering the system zone; a `free()` on another thread in that gap is an uncatchable SIGTRAP at `libtoolkit+0x4c9f4`. Later libraries (`libipc`, `libawt_toolkit`) see PartitionAlloc already registered and do not swap again (measured with an interposer). Two layers close the window: - **The native agent** (`native/toolkit-preload-agent`, built by `prepareToolkitPreloadAgent`, shipped as `$APPDIR/resources/toolkit-preload/libbosstoolkitpreload.dylib` and named by `-agentpath` in the launcher). It loads the toolkit from `Agent_OnLoad`, when the only threads are the launcher's two idle ones, so nothing can free during the swap. It decides nothing: it loads what `ToolkitPreloadManifest` recorded on the previous launch, and only while every file that decision read is unchanged. 9.5.37 still crashed because the JVM's own C2 compiler thread freed mid-swap, which no Java-side ordering can prevent. - **`ChromiumToolkitPreload`**, the in-JVM preload on the main thread before AppKit. It still runs every launch (a no-op after the agent), covers the launches the agent skips, and writes the manifest for the next one. Its KDoc is the canonical account; keep detail there. Rules for anyone touching it: - **Preload only from the directory `FluckEngine.resolveEngineDir` boots.** A second copy from another path does not swap again, but it registers duplicate Objective-C classes (including `NSWindowSwizzler`), so the agent skips on ANY doubt rather than guess. - **Anything the engine decision starts reading must be guarded in the manifest** (`ToolkitPreloadManifest.inputsFor`), or the agent can load an engine this launch would not pick. - **A packaged launcher must never name an agent it does not carry**: a missing `-agentpath` library stops the JVM from starting. `verifyToolkitPreloadAgent` fails the build for that, and `Agent_OnLoad` always returns `JNI_OK`. - **JxBrowser must stay in the host class loader.** A plugin that bundled JxBrowser would get `already loaded in another classloader` for a library the host preloaded. - **Known gaps:** the first launch after an install, engine change or app update has no valid manifest and keeps only the in-JVM preload; a first-run download-then-boot gets no preload, so packaged macOS relaunches after the download (`ChromiumBootstrap.onEngineDownloadComplete`) instead of booting with AppKit running (9.5.39 crashed there on a fresh install), carrying queued `boss://` links and files across in `RelaunchHandoff` (replayed as `EXTERNAL`); a native allocation failure is still a `brk #0` (only moving JxBrowser out of process fixes that). Off switch for both layers: `BOSS_TOOLKIT_PRELOAD=false`. - **Re-measure after a JxBrowser bump.** Check the zone swap still sits in a static initializer and still happens only once per process before trusting any of the above. ## Browser telemetry, and how to turn it off The integrated browser reports which sites BOSS is used with and how - page views, dwell vs active time, navigation depth, and in-page interactions (clicks, scroll depth, field focus, form submits, copy/paste). `BrowserAnalytics` is the privacy boundary: a full URL goes in and only an **eTLD+1 registrable domain** comes out, and the injected collector is written never to *read* page text, input values, labels, ids or URLs in the first place. See its KDoc for what is deliberately not covered. **Kill switch**: `BOSS_BROWSER_TELEMETRY_DISABLED=true` (also `1` / `yes` / `on`), or the `boss.browser.telemetry.disabled` system property. It is enforced at the single point every browser event is published, *and* stops the collector script being injected into pages at all. Read once at startup, so a change needs a restart. There is no Settings row and no per-site exclusion. **Two things a deployment should know before building on this data:** - **Any installed plugin can subscribe.** `PluginContext.applicationEventBus` is ungated, so `BrowserEvent` / `BrowserInteractionEvent` are readable by every plugin, not only the analytics plugin that owns *egress* consent. This is the largest change in what a third-party plugin can observe, and it is a deliberate choice to keep the bus uniform rather than an oversight - gate at install time by choosing which plugins are allowed, not by trusting the bus. - **The interaction numbers are attacker-influenceable.** `window.__bossInteraction` is reachable by any script on the page, so a site can fabricate its own engagement (bounded by the rate limiter) or suppress it by pre-setting `window.__bossInteractionStarted`. The sanitizers bound what can be *smuggled* through; nothing bounds a site lying about its own usage. Treat these as indicative, not as measurements, wherever a site has an incentive to lie. - **A page can observe the current trackpad gesture token.** The injected swipe bridge exposes the process-wide contact id and begin epoch while fingers are down. It does not expose deltas, but a page can poll the bridge and infer that a trackpad contact is active. - **Every project the user opens is now on the bus, not only plugin-initiated ones.** `ProjectChangeEvent` used to be published from `ProjectDataProviderImpl.selectProject` alone, so a path reached plugins only when a plugin had asked for the switch. It is now announced from the window state's own `ProjectSelectionCallback` (`WindowProjectStateRegistry.newState` / `ProjectChangeAnnouncer`), which covers the startup restore, the top bar picker, the CLI, deep links and the KERNEL-mode gRPC bridge. Project paths routinely contain usernames, so this widens *when* a filesystem path reaches every installed plugin, not *what* - the same install-time-gating stance as the bus above applies. In particular, `boss://` links can originate outside BOSS and a deep link that would start a terminal command, and a plugin action link, consult `DeepLinkOrigin`; project/file deep links still do not, so an externally opened project link can trigger this broadcast without operator confirmation. Plugin action links are the exception: external and in-process-plugin requests are held for confirmation before their registered handler runs, including when they arrive before any window exists, in which case they wait for the first one. It is recorded here because this paragraph is the canonical list of what a third-party plugin can observe. - **`PluginContext.projectSearchProvider` is the first UNGATED WRITE surface.** Like the event bus it is available to every installed plugin, but where the bus is a read, its `replaceInProject` rewrites file contents anywhere inside the open project (the `project_replace` MCP tool sits behind a `project.replace` permission on the *MCP* side, but the provider itself is ungated). Confinement is to the open project only - `resolveFile` refuses paths outside it, canonical and symlink-checked. A plugin that needs project search should be vetted the same way one that subscribes to the bus is. - **`WorkspaceContextMcpProvider` exposes open file paths and browser URLs to MCP agents under explicit permission and policy gates.** Where `IntrospectionMcpToolProvider` intentionally emits magnitudes only (counts of tabs/terminals, deliberately refusing to disclose file paths or browser URLs), `get_workspace_context` and `get_active_editor_file` exist specifically to let operator-attached AI coding agents inspect active workspace context, locate open files, and reference current browser URLs. `get_workspace_context` inventories tabs across all Spaces (current and preserved workspaces across all open windows). To prevent ungated exposure of background workspace paths and cross-window URLs to untrusted callers, both tools require the `workspace.context` permission (which admin status bypasses under the standard RBAC rules) and are enrolled in `McpMutatingToolCatalog.KNOWN_MUTATING_TOOLS` as sensitive reads, routing invocations through the approval-requiring ASK default so operator approval remains mandatory. ## Two-finger swipe navigation (macOS) A two-finger horizontal trackpad swipe navigates back/forward. It is detected **inside the page** (`BrowserSwipeNavScript` + `swipe-nav.js`), because under `HARDWARE_ACCELERATED` the browser is a native surface and Compose does not see the wheel. JxBrowser's AWT callback sees deltas but has already lost the native finger and momentum phases. JxBrowser's `enableOverscrollHistoryNavigation` does NOT provide this - measured 2026-08-28, it does nothing for a trackpad in either rendering mode, because it is a touchscreen feature. The recognizer is a port of Chrome's own (`history_swiper.mm`): three cancellation tiers, with vertical measured as a path length and horizontal as net displacement. Chrome's absolute thresholds are fractions of the trackpad from `NSTouch.normalizedPosition`, which a page cannot see, so those carry over as the same fractions of the commit distance. **It commits on a real CoreGraphics Ended phase, never on an inactivity timeout.** A listen-only session event tap assigns each finger sequence an id and accumulates its final point deltas. The page latches that id while deciding scroll ownership; only the matching native release can decide. A stationary hold therefore remains cancellable indefinitely. Cancelled phases reset without navigating, and momentum has no active finger id and is ignored. Native final displacement magnitude is authoritative at release (the page chooses direction) so renderer/native queue reordering cannot hide a late easing-back or reversal. If Input Monitoring preflight fails the feature fails closed and Settings shows how to grant access; this path never requests permission itself. The observer is started off the UI thread only while the effective setting is enabled. Disabling cancels claimants immediately and releases the native source and tap within the bounded run-loop poll. Re-enabling retries permission/setup; run-loop failures cancel the contact, clean up resources and report a distinct failure state. Availability reads and browser registration do not start native observation. `boss.browser.swipe.phase` remains compatible: `id:active:beganAtEpochMs[:previousTerminatedAtEpochMs]` or `id:ended|cancelled:netX:verticalPath:nativeRejected:reversed`. The fifth terminal field is the native reducer's verdict, not the page's. Active publication occurs only at contact begin, not on every movement. `boss.browser.swipe.terminals` additionally retains the last 32 terminal records, separated by semicolons, published before the next active contact. Companion fluck-browser#45 reconciles by contact ID both before handling a new wheel and in its watchdog. Reads are non-destructive across surfaces; missing/evicted evidence cancels. These deltas are native CoreGraphics point deltas, not a guarantee of CSS-pixel or Compose-unit equivalence. Physical trackpad calibration still needs to cover browser zoom, slow drags and the separately tuned home surface. `SwipeNavParityTest` runs shared sample fixtures through the native reducer and the actual page script, including the host-generated release statement; it also pins cancellation constants. One cross-process ordering limit remains: the cutoff rejects AWT events stamped at or before the previous native termination, but cannot identify an old OS event that AWT dispatch stamps only after the new contact begins. Arbitrarily delayed dispatch still needs real backlog testing before adding a custom FIFO between the CoreGraphics tap and JxBrowser's input callback. **Past the commit distance, vertical drift stops cancelling** (`reachedCommit`). Vertical is a path length and only ever grows, so every event after the crossing was one more chance to cancel a swipe the user had already completed. Before the line the three tiers apply unchanged; after it, only easing back or reversing can still cancel. Native swipe-back behaves the same way. **Two host-side windows, for two different things** (`BrowserSwipeNavBridge.kt`). `SWIPE_NAV_DEBOUNCE_MS` (32ms, any direction) catches a double-dispatch bug in the bridge. `SWIPE_NAV_REPEAT_MS` (400ms, same direction only) remains defense in depth against duplicate bridge delivery across a navigation. A reversal is held only for the shorter debounce window. **Off switch**: `Settings > Browser > Trackpad`, stored in `~/.boss/swipe-nav.json`, or `BOSS_BROWSER_SWIPE_NAV=false` (also `0` / `no` / `off`). The environment wins, and the Settings row says so. The setting is published as a **system property** because the browser plugin draws the home surface, lives in another repo, and `PluginContext.settingsProvider` reads nothing - so both halves of the gesture read one key. An unparseable value owns nothing. Covered by running it: `node scripts/test/test-swipe-nav.js`, in `build.yml`. **Three modules apply the Compose compiler with no Compose code, on purpose** - `plugin-logging`, `plugin-bookmark-types` and `plugin-workspace-types`. `boss-plugin-api` ships this same `ai.rever.boss.plugin.logging` package and *is* a Compose project, so its `ComponentLogger` carries the synthetic `$stable` field. This module's copy shadows it parent-first inside plugin classloaders, so a plugin that merely holds a `ComponentLogger` **property** emits `getstatic ComponentLogger.$stable` - which links against the api jar at build time and is missing at runtime. `BinaryCompatibilityValidator` then rejects the *entire* plugin and the host disables it as binary incompatible. That made secret-manager 1.2.6 and 1.2.7 unloadable on every host. `$stable` was verified (javap, member by member) to be the only public difference between the two copies, so emitting it here makes them interchangeable and repairs already-built plugins with no api release. Scope: diffing every api package the host also bundles found the field missing from **15 classes across those three modules** - `ComponentLogger`/`BossLogger`/`LogEntry`/`BossLoggerConfig`/ `LogSanitizer`, `Bookmark`/`BookmarkCollection`/`FavoriteWorkspace`/`WorkspacePanelTarget`, and `LayoutWorkspace`/`TabConfig`/`PanelConfig`/`SplitConfig`/`BreadcrumbConfig`/`WorkspaceSerializer`. Only `ComponentLogger` had actually bitten us; the data types are more exposed, since plugins hold them as properties routinely. `plugin-logging` gets the Compose runtime as **`compileOnly`** (the compiler needs it, the generated field does not, so the published POM stays clean). The other two already had it as `implementation` for `@Immutable`, so only the compiler plugin was added there. `LoggingStableFieldTest`, `BookmarkStableFieldTest` and `WorkspaceStableFieldTest` pin this, and each module's publish task depends on its own `desktopTest` - co-location alone does *not* put a test on the publish path. Do not delete them to make a build pass. The guard is **not** universal: `release.yml` runs only `createDistributable`/`packageDmg`/ `packageMsi`/`packageDeb`, so app **packaging** never runs these tests. Merges are gated (PR CI runs `./gradlew build` → `check` → `allTests`; note a bare `./gradlew test` does *not* cover them, because a `jvm("desktop")` target registers `desktopTest`, not `test`), and the Maven publish path is now gated - packaging relies on those. **Publish `plugin-bookmark-types` and `plugin-workspace-types` together.** bookmark-types has `implementation(projects.pluginPlatform.pluginWorkspaceTypes)`, so its POM pins the sibling at the current project version. `publish-maven-central.yml` takes a free-form `packages` input, and dispatching bookmark-types alone would ship a POM requiring a `plugin-workspace-types` version that does not exist on Central. Bookmark id generation also calls `UniqueIdsKt` at runtime, so an older workspace-types jar cannot substitute for the sibling version. `all` is safe - workspace-types publishes first. BossConsole#81 tracks the durable guard: diffing public members against the api jar `plugin-api-core` already downloads, covering all eight duplicated packages rather than this one field. ## Build and Deployment **GitHub Actions**: `build.yml` (multi-platform tests), `release.yml` (signed builds) **Secrets Required**: `JXBROWSER_LICENSE_KEY`, `SUPABASE_ANON_KEY`, code signing certs ## Development Notes **Current Focus**: RBAC, LSP integration, performance monitoring, cross-device auth **Known Issues**: - Issue #33: Remove hardcoded credential fallbacks after testing - Issue #34: Use JxBrowser for login instead of system browser ## Key Files **Client**: `AuthService.kt`, `SessionManager.kt`, `DesktopPasskeyService.kt`, `SupabaseConfig.kt` **Server**: `supabase/functions/passkey/services/auth.ts`, `utils/jwt.ts`, `utils/crypto.ts` **Config**: `version.properties`, `build.gradle.kts` ## Deep Links App registers `boss://` protocol for authentication callbacks from external browsers. That includes `boss://auth/callback`, the Google / Apple sign-in return, which is acted on only while a sign-in this process started is waiting (`OAuthSignInService`). Linux registers the scheme at startup through `LinuxProtocolHandler`, as Windows does through `WindowsProtocolHandler`. Because the scheme is registered with the OS, a `boss://` link is not evidence that the operator asked for anything - any program that can ask the OS to open a URL produces the same input. Entry points therefore tag each link with a `DeepLinkOrigin`: - `OPERATOR_CLI` - BOSS's own CLI parsed it out of this process's `argv` (`createBossCLI`), i.e. arguments passed to the BOSS executable directly. - `EXTERNAL` - the OS URL-open handler, a `boss://` argument from the registered protocol handler, or a forward over the single-instance channel that said so. Also the default for an unstated origin, so a new caller that forgets to say gets the cautious handling. Three hosts consult it. The first two share a reason - each can type a command into a shell - and the third reaches a plugin's own code instead: - `boss://terminal?command=`: an `OPERATOR_CLI` command runs as before, anything else is shown to the operator for confirmation first (the `boss` shell shim converts to a `boss://` URL and opens it via the OS, so its `terminal -c` still works, with one confirmation). - `boss://workspace?path=`: a Space's terminal tabs run their `initialCommand` when it is applied, so an `EXTERNAL` load of a Space that carries any is held (`spaceLoadDisposition`) and `SpaceLoadApprovalDialog` lists every command before anything loads. A Space with no terminal commands, and the operator's own `boss workspace`, load as before. The origin rides on `CLICommand.LoadWorkspace` through the cold-start readiness queue to `WorkspaceLoadEvent.requiresConfirmation`, because only the window parses the file and so only it knows whether there is anything to confirm. - `boss://plugin?id=…&action=…`: the link dispatches into a plugin's registered `DeepLinkActionHandler`, which is a program the operator did not ask to run, so an `EXTERNAL` request is held (`pluginActionDisposition`) and `PluginActionApprovalDialog` shows the handler, the action and the parameter KEYS - never a parameter value, which is attacker-chosen text. Malformed prompt tokens are refused outright. No other host consults it. A terminal request with no usable window is refused. A **plugin action** with no usable window is instead *retained* by `PluginActionEventBus` until some window claims it, because that is the ordinary cold-start path rather than an edge case: `CliBootstrap.dispatchPostLock` runs an argv link before `application {}` builds the first window, so refusing there meant a link clicked while BOSS was not running was never put to the operator at all. A retained request has not run and still cannot run without a confirmation, so this widens nothing. The registry is bounded (`MAX_PENDING`); a request arriving when it is full is refused, not dropped silently. Every open window is offered every retained request and `shouldClaimPluginAction` decides whose it is - the window it resolved to, or any window once that one has closed - so exactly one window shows it. A window claims one request at a time, only while nothing is on screen (`PluginActionApprovalQueue.canClaim`), as the dependency bus does, so every other request stays retained for the next window. Closing a window after its prompt appears can still abandon that one claimed request. Because the window's own queue holds only the prompt on screen, the prompt's "(n pending)" counts that one plus every request still retained on the bus (`pluginActionBacklog`), so a flood of links is visible. The wiring lives in `PluginActionApprovalPrompt`, not inline in `BossAppDialogs`, so a test drives the real count. A retained request carries no age and does not expire: the registry is bounded and a request reaches its handler only through the prompt, so a prompt shown long after the link was clicked still fails closed rather than acting on its own. That is a recorded decision, not an oversight - a TTL, or the arrival time in the prompt text, would make a late prompt easier for the operator to place. **Single-instance channel**: `SingleInstanceManager` publishes `~/.boss/run/single-instance` (owner-only) with the channel endpoint and a token minted at startup, and listens on a Unix-domain socket in that directory (macOS, Linux) or a loopback port (Windows). Every request must present the token, "another instance is running" means something answered on the channel rather than a pid existing, and a descriptor nobody answers on is reclaimed. A forwarded operator-origin plugin action (`boss://plugin?id=...&action=...`) is acknowledged only when its handler reports true. An external action is acknowledged when it is queued for confirmation, before anything runs. Missing ids, refused actions, missing handlers, declined and throwing operator-origin handlers report failure. The channel waits up to five seconds for a direct dispatch; a timeout reports an unknown outcome and cancels it if it is still queued. An already-running synchronous handler cannot be interrupted. Startup therefore never retries plugin actions automatically, even after a lost response; auth and other open requests retain their existing retries. Panel-open links still only acknowledge queuing. The wire verdict does not change OS/CLI callers that ignore it. ## Every OS open request becomes a `boss://` link Links and files arrive through four different doors and all four normalise to one deep link before anything else happens, via `fileDeepLinkFor` and `OsOpenArguments`: | Door | Platform | Handler | |---|---|---| | open-URI AppleEvent | macOS | `Desktop.setOpenURIHandler` | | open-file AppleEvent | macOS | `Desktop.setOpenFileHandler` | | a path or URL in `argv` | Windows, Linux | `DeepLinkHandler.processCommandLineArgs` | | a forward over the single-instance channel | all | `main.kt`, when the lock is held | The point of funnelling them is that `boss://file` and `boss://url` already carry the parts each new door would otherwise have to re-implement: path validation, the window resolve through `WindowFocusManager.resolveActionableWindowId`, and the `FileHandlerService` / `URLHandlerService` counters that stop the New Tab dialog destroying the tab being created during a cold start. Four things that were broken before this and are easy to break again: - **Nothing registered an open-file handler at all.** BOSS declared `CFBundleDocumentTypes`, appeared in Finder's Open With menu, launched when a file was double-clicked, and then did nothing with it. macOS discards the AppleEvent once the queue drains with no handler set. - **`processCommandLineArgs` was gated on Windows.** Linux delivers a protocol URL in `argv` too (`Exec= %U`) and the JDK's X11 peer supports no `APP_OPEN_URI` action, so a Linux user with BOSS as their default browser had every cold-start link silently dropped. - **A CLI invocation must not be extracted as an open request.** `OsOpenArguments` returns nothing when any argument names a `createBossCLI` subcommand, or `boss file /tmp/x.md` opens the file twice. `OsOpenArgumentsTest` pins the subcommand list against `BossCommand.kt`. - **A `file://` URL is not a path.** `Exec=%U` hands file managers' URLs over, and `File(URI)` rejects any authority component, so `file://localhost/tmp/x` needs the redundant host stripped before it resolves. `CLISecurityValidator` now has two path checks, and using the wrong one is a visible bug either way. `isValidOpenTargetPath` is for a file about to be **read** into the editor: NUL rejected, canonicalised, nothing else. `isValidPath` is for a path that may reach a **shell** and rejects `..`, `$`, `&`, `;`, `|` and a backtick - which are ordinary filename characters, so applying it to a file open meant `Q&A notes.md` could not be opened at all. ## Default applications, and the engine bundle that stole them `boss-file-types.json` (in `composeApp/src/desktopMain/resources`) is the single source of truth for what BOSS can be made the default handler for: five categories over 83 extensions, which is exactly what `EditorLanguages.EXTENSIONS` can highlight. Three consumers read it and one test pins it: - `buildSrc/BossFileTypes` generates the `CFBundleURLTypes`, `CFBundleDocumentTypes` and `UTExportedTypeDeclarations` blocks at build time. - `FileTypeCategories` serves the Settings screen and the registration calls. - `WindowsFileTypeHandler` and `LinuxFileTypeHandler` derive ProgIDs and MIME associations from it. - `FileTypeCategoriesTest` asserts its extension set and its extension-to-language map equal `EditorLanguages.EXTENSIONS`, so what BOSS offers to open cannot drift from what it can render. (`buildSrc/BossFileTypesTest` checks the same thing by regex, because `buildSrc` compiles first and cannot see the real object.) **Launch Services can only make an app the default for a *type*, never for an extension.** 42 of the 83 extensions resolve to a system UTI, which BOSS claims as-is - 31 distinct UTIs, since several extensions share one (`.cpp`, `.cc` and `.cxx` are all `public.c-plus-plus-source`). The other 41 have no system UTI (`UTType(filenameExtension:)` answers with a `dyn.*` placeholder, which cannot be set as a default), so BOSS exports its own type for them, grouped by language into 24 declarations. The per-extension answers in the resource are **measured, not derived**, and three of them are the reason: `.ts` resolves to `public.mpeg-2-transport-stream` (a video container), `.as` to `com.apple.applesingle-archive` (a binary archive) and `.edn` to `com.adobe.edn`. Claiming any of those would make BOSS the default application for a format it cannot open, so they are recorded as `rejectedSystemType` and get an exported type instead. **Windows registration is one `reg import`, not hundreds of `reg add`s.** `WindowsRegistryScript` generates a `.reg` script and `WindowsFileTypeHandler` imports it in a single process; the status is one `reg query /s` parsed once. The per-`reg add` version ran on the order of 415 processes for a five-category "Set all" plus 83 more to read the status, each with its own timeout, from a Settings screen and from the first-run offer at startup. It also removes a quoting hazard rather than trying to get it right: the `shell\open\command` value is `"C:\path\BOSS.exe" "%1"`, a string that both begins and ends with a double quote, and Java's Windows `ProcessImpl` treats an argument in that shape as already quoted and passes it through unescaped - so `reg` would have stored only the exe path and treated `"%1"` as a stray token, killing the one write that makes a double-clicked file reach BOSS. That premise could not be verified on a mac (the JDK ships only the Unix `ProcessImpl`), which is itself a reason to prefer the form where the question cannot arise. In a `.reg` file the value is escaped by `regEscape` and never passes through a command line. **The three platform handlers share one fold.** `DefaultHandlerState.reduce` is the single definition of "BOSS only when every type in the category is BOSS, and `OurEngine` outranks `Other`". Each handler had its own copy; they agreed, which is the only reason nothing was broken, and a fourth copy would not have. **`Settings > Browser` reads that fold too, and used to hold a boolean instead.** `DefaultBrowserSection` is the older surface for the same two categories, and it flattened the answer to `Boolean?` - which cannot tell "Safari holds http" from "a BOSS component holds http". So on exactly the machines the three-way state exists for, it said "BOSS is not your default browser" while `Settings > Default Apps` reported `OurEngine` and offered a Repair: two screens, one machine, opposite stories, and the one a user reaches from `Settings > Browser` was the wrong one. It now renders all three states, offers **Repair** for `OurEngine` (the same claim call, relabelled, because setting and repairing are the same work), and **re-reads** the state after a successful set rather than assuming `Ours`. `browserHandlerState()` is not on the `expect` declaration: `DefaultHandlerState` is desktopMain and lifting it into commonMain to widen an interface with one implementation would be churn. **Windows counts schemes now, and could not before.** `web-links` is schemes with no extensions, and `WindowsFileTypeHandler` read only the per-extension `UserChoice` keys - so that row reported `Other` on every machine, including one where BOSS did hold http and https, and `register` returned early on "no extensions" without ever writing the `StartMenuInternet` entry that puts BOSS in the browser list its own settings page sends the user to. Both sides now consult `schemesFor`, through `WindowsDefaultBrowserHandler.schemeState` and `registerAsBrowserCandidate` rather than a second copy of those registry reads and writes. macOS always counted both. The `reg` calls cannot run on a mac or Linux runner, so what is pinned in a test is the data fact underneath (`FileTypeCategoriesTest`: `web-links` has schemes and no extensions, `web-pages` the reverse) - if that flips, the reason for reading both sides flips with it. `OurEngine` is unreachable on Windows and Linux, and that is a fact about those platforms rather than a gap: the second "BOSS" is a macOS `.app` that Launch Services indexes because it declares `CFBundleURLTypes`. Nothing registers the engine under `StartMenuInternet` or writes a desktop entry for it. The shared type is still used on all three so the card has one story to tell. **The engine bundle used to be a second app called "BOSS".** `~/.boss/boss-chromium/BOSS.app` is the branded JxBrowser engine, and being Chromium it inherited `CFBundleURLTypes` (http, https) and `CFBundleDocumentTypes` (`public.html`). Branded, its `CFBundleName` is also "BOSS" and its id is `ai.rever.boss.browser` - so System Settings offered two indistinguishable "BOSS" entries under Default web browser, and picking the wrong one handed every link to a bare rendering engine with no window, no tabs and no session. Measured on a real machine: http, https and `public.html` all resolved to `ai.rever.boss.browser` while the app itself reported "not the default browser". Both halves of the fix matter. `build-chromium-branding.yml` now deletes both keys from the engine's `Info.plist` before the re-sign (`EngineBundlePlistStripTest` pins that, including the ordering: after the re-sign the edit breaks the seal), so future engines are clean. And `DefaultHandlerState` is a three-way answer - `Ours` / `OurEngine` / `Other` - so an install already in the broken state is told what actually happened and offered a Repair rather than "BOSS is not your default browser", which is the wrong story to tell somebody who did set it. **An existing install cannot be fixed by a version number.** The engine fix ships inside a *rebuild* of an already-published engine, so `version.txt` reads the same before and after and `ChromiumAutoDownloader.isChromiumInstalled`'s equality test short-circuits. So that function also asks the extracted bundle what it actually declares (`declaresBrowserTypes`): on macOS, an `Info.plist` still carrying `CFBundleURLTypes` or `CFBundleDocumentTypes` invalidates the directory and the existing download path replaces it. Three things about that check: - It sits beside the execute-bit check in the same function, which exists for the same reason: catching a cached engine that is the right *version* and the wrong *content*. - It is **bounded to one attempt per version** by a marker outside the engine directory (`~/.boss/boss-chromium.types-repair`; a re-download replaces the directory itself). Without it, an engine that still declares the keys after the re-download - the rebuild never published, or published unrepaired - would re-fetch ~160 MB on every launch, silently. - It **fails closed**: an unreadable or absent plist answers false, because forcing a large download over a file we could not read is the worse mistake. It matches `NAME` rather than the bare key name, so a comment or a string value naming the key is not a declaration. `lsregister -u` on the engine bundle was tried first and does nothing: it returns 0 and the bundle is still a candidate for `https` and `public.html` afterwards, so there is no download-free way to take it out of Launch Services. Launch Services is reached through `utils/mac/LaunchServices.kt`, bound with JNA. It replaced a `swift ` shell-out per call, which needed Xcode installed and so could not work at all on most machines; the Swift scripts remain only as a fallback for URL schemes if `Native.load` fails. ## The quick switcher belongs to a plugin now `Ctrl+Space` used to set `BossAppState.showTopOfMindDialog` and the host drew its own `TopOfMindDialog`. Both are **deleted**. It now dispatches `open-quick-switcher` at the Top of Mind plugin and opens that plugin's panel, exactly as the workspace button already did with `open-workspace-picker`; both helpers live in `components/plugin/TopOfMindActions.kt`. **The panel id is `PanelId("top-of-mind", 5)`.** The removed `TOP_OF_MIND` constant used `PanelId("topofmind", 2)` - a different id string from the one the plugin registers - so opening by it matched nothing at all, silently. Only the `panelId` and `pluginId` are compared, so the order is carried for honesty rather than for matching. **When `dispatch` returns false there is no fallback dialog, and that is the point.** Falling back would hide the fact that the plugin is missing behind a switcher that looks fine but sees only this window. `openTopOfMindQuickSwitcher` instead says why, reusing what already exists rather than adding a second install path: | State | What happens | |---|---| | not installed | `MissingPluginOffer.offerIfMissing`, which raises the host's store-backed `MissingDependencyDialog`. It is `userInitiated`, so a keypress re-asks after a previous dismissal. | | installed, switched off | An **Enable** button, through `MissingHandlerPluginEventBus` - the host's only enable-offering dialog. Installing something already installed cannot fix it. | | installed and running, no handler | A status line: an older plugin build, update it in the Toolbox. | | anything else | A status line naming the state (no access, incompatible, still starting). | The decision is `pluginSectionAbsence`, the same pure function `PluginSettingsUnavailableNotice` uses, **moved from `settings/sections/` (desktopMain) into `components/plugin/` (commonMain)** so both callers share one ordering. That ordering is the part worth not copying: permissions first because an inaccessible plugin is recorded DISABLED too, and incompatible/disabled before installed because `MissingPluginOffer.isInstalled` counts both as installed. `servesNoPanel` is passed `true` from the keypress path and reads there as "serves no such action" - reached only after `dispatch` has already said no. Two things the enable path knows that the install path does not. `MissingHandlerPluginBus` remembers a "Not now" for the session and drops every later report, so `wasDeclined` is asked first and a declined plugin falls through to the status line rather than to silence. And its `tabTypeId` field carries the action name here, which only feeds its log lines and the collector's "has it registered since?" re-check against `TabRegistry` - no tab type is called `open-quick-switcher`, so that re-check can never suppress the prompt. **The host still does not depend on the plugin.** api -> host is forced (`ActiveTabsProvider` is `@HostImplemented`) and plugin -> host is forced (`minBossVersion`); a host that required the plugin would close a cycle. It builds and runs without it and merely declines to do the job itself. `TopOfMindStateHolder` and `TabCollector` **stay**: `GlobalSearchService` reads the holder, and the plugin api's new `ActiveTabsProvider.allWindowTabs` is derived from it, with `refreshAllWindowTabs()` calling `TabCollector.refreshGlobalState`. That is deliberately the only cross-window tab walk - `ApiActiveTabsProviderAdapter` is per window, so an adapter collecting its own would have N windows each walking every other window's split trees on their own schedule. ## One pane, one name `ActiveTabData.splitPosition` reaches every plugin with the name the window's own vertical tab bar prints on that pane's group header - "Left", "Top right", "Pane 3". It had carried a comment promising exactly those values since it was declared and was **never populated**, so every consumer saw null and invented its own naming from the saved `SplitConfig`: a different source of truth, which describes the tree rather than the screen (so a nested pane came out "TOP > LEFT" where the bar says "Top left") and goes stale the moment a pane is split without saving. `paneGlyphs` in `PaneGlyph.kt` is the normalisation both sides share. The answer for one pane depends on all of them, so it takes the whole set; `WindowVerticalTabBar` had the only copy, and a second one in `SplitViewState` is precisely how the two names would drift. `splitPositionsFor` then applies the bar's own two rules - a `panelName` the user gave beats the derived position, and an unmeasured pane falls through to "Pane N" - and answers nothing at all for a lone pane, because a position there claims a divider that does not exist. **A workspace running behind the one on screen answers too.** Its panes were never composed, so there are no measured rectangles; the rectangles come from its split tree with every divider assumed centred, which cannot change the answer - `paneLabel` asks only which edges a pane touches and which axis it spans, and no divider position changes either. So a workspace names its panes identically before and after it comes on screen. That path deliberately does **not** consult `panelName`: panel ids are unique only within one tree (every workspace's first pane is `main`), so a name given to one pane would be returned for the pane of that name in every other workspace. ## Dropping a tab into a pane, at a position The window's vertical bar registers one rectangle per PANE (`RegisterGroupBounds` carves the scrolling column into slices), so "dropping anywhere in a pane targets that pane" has always worked. What it did not carry was a POSITION: `tabBarTargetAt` answered `TabDropTarget.ExistingPanel(panelId)` for any pane but the drag's source, which appended and drew no line, so the only way to say where a tab should sit was to move it and then reorder it. `ExistingPanel` now carries a nullable `targetIndex`, and the slot comes from the SAME arithmetic a reorder uses - `reorderIndexFor` over `tabBounds`, filtered to the target pane. Five things about that: - **The rectangles are the target pane's, not the source's.** Tab bounds are keyed `panelId:tabId`, so a pane other than the source has its own measured rows to compare against. Reading the source's is the bug the test `the index comes from the target pane's rows` pins. - **Null means append, and it has to stay expressible.** Three drops name a pane and no position in it: the centre of a panel's content area, a sidebar panel dragged out onto one (`ProcessPendingPromoteToTab`), and a bar whose tabs have not been measured yet. Defaulting those to 0 would land the tab at the head of a list the user never pointed at. - **The index is carried through UNADJUSTED.** A reorder's is nudged down when the source sat earlier in the same list, because removing the tab shifts everything after it. A cross-pane move takes nothing out of the destination, so the slot the indicator drew is the slot the tab lands in. - **`insertionEdgeFor` is the drawing rule, and it is pure.** Every slot is drawn by the row BENEATH it, and the one slot with no row beneath - past the end - rides the last row's trailing edge, so a boundary two rows touch is never drawn twice. `InsertionEdgeTest` asserts that as a property over a whole list ("every slot is drawn exactly once") rather than case by case, because a doubled boundary and a lost final slot each satisfy every individual case. - **The pane fill and the line say different things.** The fill (`PanelDropZoneOverlay`) is which pane will take the tab; the line is where in it. Both are wanted, and the line is read through one `derivedStateOf` inside each ROW rather than off `dropTarget` in the bar's body - this bar renders every tab in the window, and `dropTarget` changes at pointer rate. The fill needed the same treatment for the same reason: `ExistingPanel` used to be constant while the pointer moved within one pane, and `RenderSplitNode` read it in its body, so a pane's whole subtree - its tab content included - would now recompose every time the pointer crossed a row. It collapses to `PanelDropHighlight`, three answers that change a handful of times in a drag, read inside the overlay. That moved the overlay to `components/overlays/`, where an overlay belongs and where the package name has no underscores for detekt's `PackageNaming` to reject. **Pinning still follows the line, and that is not automatic.** `BossMainWindowPanel` draws the indicator deliberately AFTER the pinned `SectionBreak`, so a line below the separator means the tab lands unpinned. That holds for an arriving tab because `TabDropHandler` adopts it (which appends, there being no adopt-at-index on `BossTabsComponent`) and then calls `SplitViewState.reorderWithinPanel` - the same helper `moveTabToWorkspace` uses for a named pane - which goes through `BossTabsComponent.moveTab` and therefore through `pinnedCountAfterMove`. Reach `TabsNavigation.moveTab` directly and the count silently stops matching the separator. The reorder runs AFTER `selectTab`, because `TabsNavigation.moveTab` carries `activeIndex` along with the tab it moves; selecting afterwards would need the post-move index, which is what the call establishes. Today the two cannot actually coexist - `showSections` is `!several`, so a multi-pane bar draws no separator at all - but the ordering is what makes the rule true if they ever do. **A tab gesture owns its cleanup (#690).** Both `BossTabButton` and `TabFaviconChip` run inside `withDragSession`, whose `finally` clears an interrupted gesture even when pointer input is cancelled or restarted without an end/cancel callback. Ownership is the exact `DraggingTabInfo` instance, not the tab id: a tab can appear on multiple surfaces and can start a new drag before an old handler finishes. Never clear window-wide drag state from a tab-id-only disposal hook. **A collapsed pane springs open under a dragged tab**, after the same 550ms the Top of Mind panel gives its own headers (`SPRING_LOAD_DELAY_MS`, a second constant on purpose: nothing links the two repositories at compile time). A pane that is not being worked in shows one row plus a favicon summary, so the tabs a drop would land between are not on screen to aim at, and `TabBarGroup.hoverGroup` is wired to a real pointer hover which a captured drag does not produce. Four properties, each a decision: - **The delay is what makes dragging PAST a group free.** An ordinary drag crosses every group between the tab and its destination, and reflowing at each one would move the target out from under the pointer. - **It goes through `TabGroupExpansion.hover`**, the same sticky choice a resting pointer makes, rather than a second notion of "this group is open" for the bar to reconcile. - **The pointer and the drag are re-checked AFTER the wait.** Snapshot invalidation and recomposition are not synchronous, so the effect's own key can be stale even though it is what cancelled every earlier attempt. - **It only ever OPENS.** Nothing collapses a group, and `barExited` is suppressed while a drag is in flight for the same reason: a group that re-closed on exit would take with it the tabs that were the reason to open it. Only panes that are not already expanded are candidates, so a spring never closes a group the user opened. One difference from the plugin worth knowing: the plugin gives its collapsed summary row no index, because that row stands for whichever tab the pane is showing rather than the tab at position zero. The host's single collapsed row carries its true model index (`TabBoundInfo.actualIndex`), so a drop on it lands next to the tab that was on screen and needs no exception. ## A missing plugin no longer fails silently Browser, editor and terminal tabs are plugin-provided. `addTab` logged "Dropped tab - no factory registered for its type", returned -1, and every caller ignored it: with the browser plugin absent the OS could hand BOSS a link and nothing at all appeared, which is what "BOSS is my default browser and clicking a link does nothing" was. `SplitViewState.requireTabTypeThen` now gates every open on `TabTypeAvailability.require`, which: 1. returns immediately when the type is registered (the normal case, so the fast path does not suspend); 2. otherwise **waits** through `awaitRegistryCondition` - at a cold start the plugins have not registered yet, and prompting there would be a false alarm on every launch, the same reason `WorkspaceApplier.awaitTabTypes` exists; 3. only then raises a `MissingHandlerPluginPrompt` on `MissingHandlerPluginEventBus`, which keeps unscoped delivery through a `Channel` so exactly one window asks, buffered so reporting never suspends the open, `trySend` so an overflow is refused and logged rather than silently dropped; 4. waits again, up to five minutes, for the plugin to register. **The dialog has no success callback**: installing or enabling registers the tab type, which fires the registry listeners, which completes this wait and performs the deferred open. The file the user double-clicked appears by itself. It offers **Enable**, not Install, when the plugin is on disk and `DISABLED`. Installing something already installed cannot fix it, and both halves share `PluginDependencyResolution.installedAndOnDisk` so they cannot disagree about "installed" - the trap this repo's own AGENTS.md records as having broken the dependency prompt once already. `TabTypePlugins` maps a tab type to the plugin that provides it. It has to be a literal table: the mapping lives in each plugin's `plugin.json` and when the plugin is absent there is no manifest to read. It keys on the **type string**, not the whole `TabTypeId`, whose equality includes `pluginId` and `defaultOrder`. ## The built-in layouts are TEMPLATES, and picking one materialises it `PredefinedWorkspaces.allWorkspaces` is the eight layouts BOSS ships, not eight Spaces. Seven are parameterised - `{projectPath}`, `{gitRemoteUrl}`, `{currentFile}` and `{claudeContinueFlag}` stand in for a project nobody has chosen yet - and applying one resolved those placeholders on the way to building its tabs and threw the answers away, so the Space list still said `{projectPath}` afterwards and the layout on screen belonged to no Space at all. **Two different questions, and conflating them is a bug in either direction.** | | Browser Only | the other seven | |---|---|---| | a layout BOSS ships (`PredefinedWorkspaces.allIds`) | yes | yes | | has placeholders left to substitute (`requiresProject()`) | no | yes | Being a **TEMPLATE** is the first row: identity, "one of the eight we ship", which is what the Space picker groups on. Being **MATERIALISED** on pick is the second: shape, which is what `spaceToOpen` gates on. They agree on seven and disagree on Browser Only, a single browser panel on a fixed URL. Answering the first question with the second put one of the shipped layouts in with the user's own Spaces; answering the second with the first would try to name a copy of it after a project the layout does not reference. `spaceToOpen` (`components/workspaces/WorkspaceTemplate.kt`) is the one door every pick goes through, and it MATERIALISES a template: substitutes the placeholders, sets `projectPath`, names it `"