diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc3852af..d6d8a6e8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,6 +62,13 @@ jobs:
           BUNDLE_DEPLOYMENT: ""
           BUNDLE_FROZEN: ""
         run: bundle install --jobs 4
+      - name: Live local setup E2E (fresh bundle + daemon/Rails + TUI/web parity)
+        working-directory: .
+        env:
+          BUNDLE_GEMFILE: ${{ github.workspace }}/web/Gemfile
+          HIVE_LIVE_LOCAL_WEB_E2E: "1"
+          LOCAL_WEB_E2E_ROOT_BUNDLE_PATH: ${{ github.workspace }}/vendor/root-bundle
+        run: bundle exec ruby -Itest -Ilib test/integration/local_web_setup_e2e_test.rb --name /live_fresh_setup/
       - name: hivebox golden-path E2E (claim → idea → Q&A → daemon → PR gate)
         env:
           GOLDEN_E2E_BUNDLE_PATH: ${{ github.workspace }}/vendor/root-bundle
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 39fa61fb..3e7de277 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,6 +30,105 @@ jobs:
         # (tebako/dwarfs/Boost) — the user provides Ruby 3.4 already
         # because the rest of the toolchain needs it.
         run: gem build hive.gemspec
+      - name: Build web app release archive
+        # Local (non-Docker) installs acquire the Rails app via
+        # Hive::Web::AppBundle from a version-matched release asset.
+        # The gem intentionally excludes web/; this archive is the
+        # companion artifact. Root must contain config/application.rb
+        # (no versioned wrapper directory) so AppBundle can install it
+        # without unwrapping.
+        env:
+          REF_NAME: ${{ github.ref_name }}
+        run: |
+          version="${REF_NAME#v}"
+          archive="hive-web-${version}.tar.gz"
+          # Tracked tree only — never vendor/, log/, tmp/, or local
+          # sqlite. Fail closed if the Rails marker is missing so a
+          # truncated checkout cannot ship a useless archive.
+          [[ -f web/config/application.rb ]] || {
+            echo "web/config/application.rb missing; refusing to build ${archive}" >&2
+            exit 1
+          }
+          package_parent="$(mktemp -d)"
+          # Stage only tracked web files, then replace the checkout-only path
+          # dependency with this release's exact, unpacked gem. Keeping the
+          # path inside the archive lets Bundler fetch Rails/platform gems;
+          # a partial vendor/cache would make deployment mode incorrectly
+          # require every transitive gem to be cached.
+          git archive --format=tar HEAD web | tar -xf - -C "${package_parent}"
+          package_root="${package_parent}/web"
+          gem_file="hive-cli-${version}.gem"
+          [[ -s "${gem_file}" ]] || {
+            echo "${gem_file} missing; refusing to build standalone web archive" >&2
+            exit 1
+          }
+          mkdir -p "${package_root}/vendor"
+          gem unpack "${gem_file}" --target "${package_root}/vendor"
+          mv "${package_root}/vendor/hive-cli-${version}" \
+            "${package_root}/vendor/hive-cli"
+          gem specification "${gem_file}" --ruby > \
+            "${package_root}/vendor/hive-cli/hive-cli.gemspec"
+          sed -i -E \
+            "s|^gem \"hive-cli\", path: \"\\.\\.\"$|gem \"hive-cli\", \"= ${version}\", path: \"vendor/hive-cli\"|" \
+            "${package_root}/Gemfile"
+          grep -Fqx \
+            "gem \"hive-cli\", \"= ${version}\", path: \"vendor/hive-cli\"" \
+            "${package_root}/Gemfile" || {
+            echo "failed to pin hive-cli in staged web Gemfile" >&2
+            exit 1
+          }
+          BUNDLE_APP_CONFIG="${package_root}/.bundle" \
+            BUNDLE_GEMFILE="${package_root}/Gemfile" \
+            bundle lock --update hive-cli --conservative
+          ! grep -Fqx '  remote: ..' "${package_root}/Gemfile.lock" || {
+            echo "staged web lockfile still points outside the archive" >&2
+            exit 1
+          }
+          grep -Fqx '  remote: vendor/hive-cli' "${package_root}/Gemfile.lock" || {
+            echo "staged web lockfile is missing its contained hive-cli source" >&2
+            exit 1
+          }
+
+          tar -czf "${archive}" -C "${package_root}" .
+          tar -tzf "${archive}" | grep -Ex '(\./)?config/application\.rb' >/dev/null || {
+            echo "${archive} root is missing config/application.rb" >&2
+            exit 1
+          }
+          tar -tzf "${archive}" | \
+            grep -Ex '(\./)?vendor/hive-cli/hive-cli\.gemspec' >/dev/null || {
+            echo "${archive} is missing its version-matched contained hive-cli gem" >&2
+            exit 1
+          }
+
+          # Prove the exact bytes uploaded resolve hive-cli only from the
+          # contained path, then run the same isolated deployment install that
+          # AppBundle performs on an end-user machine.
+          smoke_root="$(mktemp -d)"
+          tar -xzf "${archive}" -C "${smoke_root}"
+          EXPECTED_VERSION="${version}" \
+            BUNDLE_APP_CONFIG="${smoke_root}/.bundle" \
+            BUNDLE_GEMFILE="${smoke_root}/Gemfile" \
+            ruby -rbundler -e '
+              spec = Bundler.definition.locked_gems.specs.find { |candidate| candidate.name == "hive-cli" }
+              abort "wrong hive-cli version" unless spec&.version.to_s == ENV.fetch("EXPECTED_VERSION")
+              source = spec.source
+              abort "external hive-cli source: #{source}" unless source.respond_to?(:path) && source.path.to_s == "vendor/hive-cli"
+              abort "missing contained hive-cli gemspec" unless Bundler.root.join(source.path, "hive-cli.gemspec").file?
+            '
+          BUNDLE_APP_CONFIG="${smoke_root}/.bundle" \
+            BUNDLE_GEMFILE="${smoke_root}/Gemfile" \
+            BUNDLE_PATH="${smoke_root}/vendor/bundle" \
+            BUNDLE_DEPLOYMENT=1 \
+            BUNDLE_WITHOUT=development:test \
+            bundle install
+          BUNDLE_APP_CONFIG="${smoke_root}/.bundle" \
+            BUNDLE_GEMFILE="${smoke_root}/Gemfile" \
+            BUNDLE_PATH="${smoke_root}/vendor/bundle" \
+            BUNDLE_DEPLOYMENT=1 \
+            BUNDLE_WITHOUT=development:test \
+            bundle exec ruby -e \
+              "require 'hive'; abort 'wrong hive-cli version' unless Hive::VERSION == '${version}'"
+          ls -la "${archive}"
       - name: Smoke test built gem
         # Confirm the gemspec is well-formed and the `hive`/`hv`
         # executables resolve before we attach the artifact to a
@@ -51,6 +150,11 @@ jobs:
           name: hive-cli-gem
           path: hive-cli-*.gem
           if-no-files-found: error
+      - uses: actions/upload-artifact@v7
+        with:
+          name: hive-web-archive
+          path: hive-web-*.tar.gz
+          if-no-files-found: error
 
   install-gate:
     name: gem-install gate / ${{ matrix.runs-on }}
@@ -116,10 +220,14 @@ jobs:
         with:
           name: hive-cli-gem
           path: dist
+      - uses: actions/download-artifact@v8
+        with:
+          name: hive-web-archive
+          path: dist
       - name: Build checksums
         run: |
           cd dist
-          sha256sum hive-cli-*.gem > SHA256SUMS
+          sha256sum hive-cli-*.gem hive-web-*.tar.gz > SHA256SUMS
       - name: Install cosign
         uses: sigstore/cosign-installer@v3
       - name: Sign checksums
diff --git a/.gitignore b/.gitignore
index cbcaa5c6..ec2d5bb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 /.bundle/
+/.bundle-path/
 /vendor/bundle/
 /tmp/
 *.gem
diff --git a/Gemfile.lock b/Gemfile.lock
index 571abed1..9648003b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -6,6 +6,7 @@ PATH
       faraday (>= 2.14.2, < 3.0)
       faraday-multipart (~> 1.0)
       lipgloss (~> 0.2.2)
+      rexml (>= 3.3, < 4.0)
       sqlite3 (~> 2.0)
       telegram-bot-ruby (~> 2.7)
       thor (~> 1.3)
@@ -96,7 +97,6 @@ GEM
     lipgloss (0.2.2-x86_64-linux-gnu)
     lipgloss (0.2.2-x86_64-linux-musl)
     logger (1.7.0)
-    mini_portile2 (2.8.9)
     minitest (6.0.6)
       drb (~> 2.0)
       prism (~> 1.5)
@@ -113,6 +113,7 @@ GEM
     rainbow (3.1.1)
     rake (13.4.2)
     regexp_parser (2.12.0)
+    rexml (3.4.4)
     rubocop (1.88.0)
       json (~> 2.3)
       language_server-protocol (~> 3.17.0.2)
@@ -144,8 +145,6 @@ GEM
     ruby-progressbar (1.13.0)
     securerandom (0.4.1)
     simpleidn (0.2.3)
-    sqlite3 (2.9.5)
-      mini_portile2 (~> 2.8.0)
     sqlite3 (2.9.5-aarch64-linux-gnu)
     sqlite3 (2.9.5-arm64-darwin)
     sqlite3 (2.9.5-x86_64-linux-gnu)
@@ -167,7 +166,6 @@ GEM
 PLATFORMS
   aarch64-linux-gnu
   arm64-darwin
-  ruby
   x86_64-linux
   x86_64-linux-musl
 
diff --git a/README.md b/README.md
index 2fca3136..4e8bfd13 100644
--- a/README.md
+++ b/README.md
@@ -244,6 +244,7 @@ The TUI is the recommended human interface and an agent-driven CLI is the recomm
 | Daemon | `hive daemon install/enable/start/status/tail/stop/disable` | Manage the global daemon service plus per-project enrollment. The service polls `hive status --json` and dispatches workflow verbs for enrolled projects. Read [wiki/operating.md](wiki/operating.md) before going live. See [docs/cli.md#daemon](docs/cli.md#daemon). |
 | Diagnostics | `hive status`, `hive doctor`, `hive rebase-status`, `hive markers clear`, `hive metrics rollback-rate` | Inspect task state, validate configured stage/reviewer skills, check whether the next run would auto-rebase, clear a recovery marker by name, or report fix-agent rollback rate. See [docs/cli.md#diagnostics](docs/cli.md#diagnostics). |
 | Registry & lifecycle | `hive init`, `hive update`, `hive uninstall`, `hive forget`, `hive prune`, `hive migrate`, `hive tree` | Attach Hive to a project, upgrade to the latest release, remove the installed CLI, prune the global registry, rename old stage folders, or print the Thor command tree. See [docs/cli.md#lower-level-surface](docs/cli.md#lower-level-surface). |
+| Local setup & web | `hive setup [--service]`, `hive web`, `hive web install/start/stop/status` | Provision local daemon + optional managed web UI on loopback (`127.0.0.1:4567`), or run Rails in the foreground. Shares XDG state with the TUI. Docker/hivebox remains a peer path. See [wiki/commands/setup.md](wiki/commands/setup.md) and [wiki/commands/web.md](wiki/commands/web.md). |
 
 Full per-command reference, every flag, every envelope field, and every exit code lives in [docs/cli.md](docs/cli.md).
 
diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist
new file mode 100644
index 00000000..0a613dce
--- /dev/null
+++ b/examples/launchd/hive-web.plist
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Sample launchd plist for `hive web` (macOS).
+
+  This file is installer-managed: `hive web install` rewrites the binary
+  path, the bin directory in PATH, and the log directory to match the
+  resolved binary + your home, then loads it. You normally do not edit
+  this by hand — re-run `hive web install` instead.
+
+  Install:
+    hive web install
+    # or by hand:
+    mkdir -p ~/Library/LaunchAgents
+    cp examples/launchd/hive-web.plist ~/Library/LaunchAgents/local.hive-web.plist
+    $EDITOR ~/Library/LaunchAgents/local.hive-web.plist
+    launchctl load ~/Library/LaunchAgents/local.hive-web.plist
+
+  Verify:
+    launchctl list | grep hive-web
+    cat ~/Library/Logs/hive-web.err.log
+    curl -sS http://127.0.0.1:4567/health/deep
+
+  View logs:
+    tail -f ~/Library/Logs/hive-web.{out,err}.log
+
+  Stop / restart:
+    launchctl unload ~/Library/LaunchAgents/local.hive-web.plist
+    launchctl load   ~/Library/LaunchAgents/local.hive-web.plist
+
+  Note the absolute-path requirement: launchd doesn't read shell rc files.
+
+  RESPAWN-LOOP CIRCUIT-BREAKER: launchd has no native StartLimitBurst.
+  With KeepAlive { SuccessfulExit: false } and a wrong ProgramArguments
+  path, launchd would respawn every 30 s forever. The /bin/sh wrapper
+  converts "binary not executable" into a clean exit 0 — which KeepAlive
+  { SuccessfulExit: false } then respects (no respawn). A real Rails
+  crash still exits non-zero through `exec` and respawns normally per
+  ThrottleInterval.
+
+  hive-web is independent of hive-daemon — separate launchd jobs sharing
+  the same XDG state and project registry.
+-->
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
+  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>local.hive-web</string>
+
+  <!-- ProgramArguments wraps the real invocation in a tiny /bin/sh
+       precheck. If the binary path passed as $0 is missing or not
+       executable, the wrapper exits 0 (clean) so KeepAlive's
+       SuccessfulExit:false STOPS respawning. If it IS executable, exec
+       hands the process tree to hive web. -->
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/sh</string>
+    <string>-c</string>
+    <string>[ -x "$0" ] || exit 0; exec "$0" "$@"</string>
+    <string>/Users/YOU/.local/bin/hive</string>
+    <string>web</string>
+  </array>
+
+  <key>RunAtLoad</key>
+  <true/>
+
+  <key>KeepAlive</key>
+  <dict>
+    <key>SuccessfulExit</key>
+    <false/>
+  </dict>
+
+  <key>ThrottleInterval</key>
+  <integer>30</integer>
+
+  <key>StandardOutPath</key>
+  <string>/Users/YOU/Library/Logs/hive-web.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/YOU/Library/Logs/hive-web.err.log</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+  </dict>
+</dict>
+</plist>
diff --git a/examples/systemd/hive-web.service b/examples/systemd/hive-web.service
new file mode 100644
index 00000000..c2b937c9
--- /dev/null
+++ b/examples/systemd/hive-web.service
@@ -0,0 +1,68 @@
+# Sample systemd-user unit for `hive web` (Linux).
+#
+# This file is installer-managed: `hive web install` rewrites ExecStart=
+# and Environment=PATH= to match the resolved binary + Ruby manager
+# detected on the host, then enables + starts the unit. You normally do
+# not edit this by hand — re-run `hive web install` instead.
+#
+# BEFORE INSTALLING BY HAND: edit ExecStart= to match where YOUR `hive`
+# binary lives. `which hive` shows it. Common paths:
+#
+#   %h/.local/bin/hive                           ← README / install.sh
+#   /usr/local/bin/hive                          ← system gem install
+#   %h/.local/share/mise/shims/hive              ← mise / rbenv / asdf shim
+#
+# Install:
+#   hive web install
+#   # or by hand:
+#   mkdir -p ~/.config/systemd/user
+#   cp examples/systemd/hive-web.service ~/.config/systemd/user/
+#   $EDITOR ~/.config/systemd/user/hive-web.service
+#   systemctl --user daemon-reload
+#   systemctl --user enable --now hive-web
+#
+# Verify:
+#   systemctl --user status hive-web
+#   journalctl --user -u hive-web -n 50
+#   curl -sS http://127.0.0.1:4567/health/deep
+#
+# View logs:
+#   journalctl --user -u hive-web -f
+#
+# Stop / restart:
+#   systemctl --user stop hive-web
+#   systemctl --user restart hive-web
+#
+# To survive logout:
+#   sudo loginctl enable-linger $USER
+#
+# This unit runs the Rails UI in the foreground (`hive web`). systemd is
+# the supervisor; Restart=on-failure brings it back if it crashes.
+# StartLimit* caps the respawn loop so a wrong ExecStart= path stops
+# cleanly with `failed` instead of relaunching forever.
+#
+# hive-web is independent of hive-daemon — they share XDG state and the
+# project registry but are separate service-manager jobs.
+
+[Unit]
+Description=Hive local web UI (Rails)
+After=default.target
+# Hard cap on the auto-restart loop. If `hive web` exits non-zero 3 times
+# within 5 minutes, systemd marks the unit `failed` instead of respawning
+# forever. Safety net for a misconfigured ExecStart= or missing Rails bundle.
+StartLimitBurst=3
+StartLimitIntervalSec=300
+
+[Service]
+Type=simple
+# systemd user services do NOT inherit the interactive shell PATH, so
+# PATH below covers incidental shell-outs and the gem wrapper's
+# `#!/usr/bin/env ruby` shebang. `hive web install` detects
+# mise/rbenv/asdf and prepends the matching shim directory automatically.
+Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
+ExecStart=%h/.local/bin/hive web
+Restart=on-failure
+RestartSec=10
+
+[Install]
+WantedBy=default.target
diff --git a/hive.gemspec b/hive.gemspec
index e54a3dec..85b6516c 100644
--- a/hive.gemspec
+++ b/hive.gemspec
@@ -60,6 +60,9 @@ Gem::Specification.new do |spec|
   spec.add_dependency "faraday", ">= 2.14.2", "< 3.0"
   spec.add_dependency "faraday-multipart", "~> 1.0"
   spec.add_dependency "lipgloss", "~> 0.2.2"
+  # Ruby 3.4 no longer ships REXML as a default gem; the launchd unit
+  # parser (daemon binary-drift status) requires it at runtime.
+  spec.add_dependency "rexml", ">= 3.3", "< 4.0"
   spec.add_dependency "sqlite3", "~> 2.0"
   spec.add_dependency "telegram-bot-ruby", "~> 2.7"
   spec.add_dependency "thor", "~> 1.3"
diff --git a/install.md b/install.md
index 8ad54e54..265b1af4 100644
--- a/install.md
+++ b/install.md
@@ -4,7 +4,20 @@ You are installing the `hive` CLI for the user. Treat this prompt as the source
 
 ## Goal
 
-Install the latest stable Hive release, install or repair the QMD wiki indexer, verify `hive --version`, set up daemon autostart, offer to run `hive init` in the current project, and report any missing runtime dependencies. Do not auto-install runtime dependencies such as `git`, `gh`, agent CLIs, or Node.js/npm; QMD is the exception once npm is already available because Hive's managed wiki refresh scripts use it. The bash installer reports its own installer prerequisites (Ruby 3.4, `curl`, `jq`, checksum tool) when that channel is used. Hive ships as a rubygem (`hive-cli`) attached to the GitHub Release; all three channels (Homebrew, AUR, install.sh) download the same signed `.gem` and run `gem install` against it. Daemon autostart is global install-time setup; project setup only decides whether that project is enrolled for daemon dispatch.
+Install the latest stable Hive release, install or repair the QMD wiki indexer, verify `hive --version`, set up daemon autostart, offer to run `hive init` (or prefer `hive setup` for full local provision) in the current project, and report any missing runtime dependencies. Do not auto-install runtime dependencies such as `git`, `gh`, agent CLIs, or Node.js/npm; QMD is the exception once npm is already available because Hive's managed wiki refresh scripts use it. The bash installer reports its own installer prerequisites (Ruby 3.4, `curl`, `jq`, checksum tool) when that channel is used. Hive ships as a rubygem (`hive-cli`) attached to the GitHub Release; all three channels (Homebrew, AUR, install.sh) download the same signed `.gem` and run `gem install` against it. Release assets also include a version-matched `hive-web-<version>.tar.gz` Rails bundle for local web installs (the gem stays lean and excludes `web/`). Daemon autostart is global install-time setup; project setup only decides whether that project is enrolled for daemon dispatch.
+
+## Local web (optional)
+
+After Hive is installed and external CLIs are authenticated, prefer one of:
+
+```bash
+hive setup                 # diagnose + daemon + enroll; then run: hive web
+hive setup --service       # also install/start managed hive-web on 127.0.0.1:4567
+```
+
+Managed lifecycle (separate from hive-daemon): `hive web install|start|stop|status`.
+Default local bind is loopback with no login; non-loopback requires `web.github.owner` or `--unsafe`.
+Docker/hivebox remains a supported alternative with its own `/data` + owner-auth flow.
 
 ## Detect
 
diff --git a/lib/hive.rb b/lib/hive.rb
index b22bfe0e..4e57ef93 100644
--- a/lib/hive.rb
+++ b/lib/hive.rb
@@ -30,6 +30,12 @@ module Hive
       "hive-daemon-enroll" => 1,
       "hive-daemon-reload" => 1,
       "hive-daemon-install" => 1,
+      # Local setup and managed Rails lifecycle contracts. These are public
+      # agent-facing envelopes just like the daemon lifecycle family above;
+      # keep their producers and checked-in schemas on the same registry.
+      "hive-setup" => 1,
+      "hive-web-install" => 1,
+      "hive-web-status" => 1,
       # Read-only inspection of the daemon's dispatch-request queue
       # (`hive daemon queue [list|show|prune]`). See AN-1/2/3 and
       # `Hive::Commands::Daemon#queue_command`.
diff --git a/lib/hive/bot/supervisor.rb b/lib/hive/bot/supervisor.rb
index c1d0ad4f..d0710091 100644
--- a/lib/hive/bot/supervisor.rb
+++ b/lib/hive/bot/supervisor.rb
@@ -779,7 +779,9 @@ module Hive
         # the first request and keep the rest as a daemon-promoted
         # continuation. The retry command is not visible to the daemon until
         # `markers clear` exits 0.
-        if commands.all? { |argv| queue_routable?(argv) }
+        if commands.all? do |argv|
+          queue_routable?(argv, project: result.project, slug: result.slug)
+        end
           return enqueue_command_sequence(commands, result, update)
         end
 
@@ -1012,7 +1014,11 @@ module Hive
         # bump the task state-file mtime — the diagnose refresh only
         # writes a diagnostics artifact — so they don't cause the
         # dual-writer bug.
-        if queue_routable?(result.command_argv)
+        if queue_routable?(
+          result.command_argv,
+          project: result.project,
+          slug: result.slug
+        )
           return enqueue_dispatch_request(result, update)
         end
 
@@ -1037,8 +1043,10 @@ module Hive
       # rewrites this kind of dispatch into a request-file write; the
       # daemon picks the request up on its next tick and spawns the
       # child. See plan 2026-05-28-002 for why.
-      def queue_routable?(argv)
-        Hive::Daemon::DispatchRequestQueue.valid_argv?(Array(argv))
+      def queue_routable?(argv, project: nil, slug: nil)
+        Hive::Daemon::DispatchRequestQueue.valid_argv?(
+          Array(argv), project: project, slug: slug
+        )
       end
 
       # Write a dispatch request for `result.command_argv` and log
diff --git a/lib/hive/bounded_subprocess.rb b/lib/hive/bounded_subprocess.rb
new file mode 100644
index 00000000..ba93cad1
--- /dev/null
+++ b/lib/hive/bounded_subprocess.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require "open3"
+require "timeout"
+
+module Hive
+  # Open3-compatible capture for short-lived probes and installers. Children
+  # start in their own process group so a deadline can terminate the whole
+  # tree, including grandchildren that inherited stdout/stderr pipes.
+  module BoundedSubprocess
+    POLL_INTERVAL = 0.02
+    TERM_GRACE_SECONDS = 0.1
+    KILL_GRACE_SECONDS = 0.2
+
+    class TimeoutError < Timeout::Error
+      attr_reader :stdout, :stderr, :elapsed
+
+      def initialize(stdout:, stderr:, elapsed:)
+        @stdout = stdout.to_s
+        @stderr = stderr.to_s
+        @elapsed = elapsed.to_f
+        super("subprocess timed out after #{format('%.2f', @elapsed)}s")
+      end
+    end
+
+    module_function
+
+    # Mirrors Open3.capture3: an optional environment Hash may be the first
+    # positional argument and spawn options (for example chdir:) are keyword
+    # arguments. pgroup is always forced on because it is the cleanup boundary.
+    def capture3(*command, timeout:, **spawn_options)
+      timeout = Float(timeout)
+      raise ArgumentError, "timeout must be positive" unless timeout.positive?
+
+      started = monotonic_now
+      Open3.popen3(*command, **spawn_options.merge(pgroup: true)) do |stdin, stdout, stderr, wait_thread|
+        stdin.close
+        out_reader = Thread.new { read_stream(stdout) }
+        err_reader = Thread.new { read_stream(stderr) }
+        deadline = started + timeout
+
+        loop do
+          process_done = wait_thread.join(POLL_INTERVAL)
+          if process_done && !out_reader.alive? && !err_reader.alive?
+            return [ out_reader.value, err_reader.value, wait_thread.value ]
+          end
+
+          next if monotonic_now < deadline
+
+          terminate_process_group(wait_thread.pid, wait_thread)
+          elapsed = monotonic_now - started
+          raise TimeoutError.new(
+            stdout: finish_reader(out_reader),
+            stderr: finish_reader(err_reader),
+            elapsed: elapsed
+          )
+        end
+      end
+    end
+
+    def terminate_process_group(pid, wait_thread)
+      signal_group("TERM", pid)
+      sleep TERM_GRACE_SECONDS
+      # Always signal the group after the grace period. The direct child may
+      # have exited on TERM while a descendant kept inherited pipes open.
+      signal_group("KILL", pid)
+      wait_thread.join(KILL_GRACE_SECONDS)
+    end
+    private_class_method :terminate_process_group
+
+    def signal_group(signal, pid)
+      Process.kill(signal, -pid)
+    rescue Errno::ESRCH
+      nil
+    end
+    private_class_method :signal_group
+
+    def read_stream(stream)
+      stream.read.to_s
+    rescue IOError
+      ""
+    end
+    private_class_method :read_stream
+
+    def finish_reader(thread)
+      thread.join(KILL_GRACE_SECONDS)
+      thread.kill if thread.alive?
+      thread.value.to_s
+    rescue StandardError
+      ""
+    end
+    private_class_method :finish_reader
+
+    def monotonic_now
+      Process.clock_gettime(Process::CLOCK_MONOTONIC)
+    end
+    private_class_method :monotonic_now
+  end
+end
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index cd809068..a3172fbc 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -1332,18 +1332,68 @@ module Hive
       ).call
     end
 
-    desc "web", "Run the hivebox web UI"
-    option :bind, type: :string, desc: "override web.bind"
-    option :port, type: :numeric, desc: "override web.port"
-    def web
-      if options[:json]
+    desc "setup", "Diagnose and provision local Hive (daemon, optional web service, project enrollment)"
+    long_desc <<~DESC
+      Full local setup for Linux/macOS:
+
+        1. Bounded dependency diagnostics (Ruby, git, tmux, gh, agents, Node, qmd, web bundle, SQLite)
+        2. Bootstrap Hive-owned assets (qmd + version-matched Rails web bundle)
+        3. Install/start hive-daemon with the invoking CLI binary
+        4. Initialize or enroll the current repository for automatic dispatch
+        5. With --service: also install/start the separate hive-web service
+
+      Bare `hive setup` leaves web as a foreground `hive web` step.
+      `--no-bootstrap` is diagnose-only (no mutations).
+      `--no-init` skips repository init/enrollment.
+      `--json` always emits a complete phase report; exit is non-zero on any hard failure.
+    DESC
+    option :service, type: :boolean, default: false, desc: "also install/start the managed hive-web service"
+    option :no_bootstrap, type: :boolean, default: false, desc: "diagnose only; do not bootstrap or mutate"
+    option :no_init, type: :boolean, default: false, desc: "skip repository init/enrollment"
+    def setup
+      require "hive/commands/setup"
+      Hive::Commands::Setup.new(
+        project_path: Dir.pwd,
+        service: options[:service],
+        no_bootstrap: options[:no_bootstrap],
+        no_init: options[:no_init],
+        json: options[:json]
+      ).call
+    end
+
+    desc "web [SUBCOMMAND]", "Run the local/hivebox web UI, or manage the hive-web service"
+    long_desc <<~DESC
+      Bare `hive web` boots the Rails control surface in the foreground
+      (default bind 127.0.0.1:4567). Managed lifecycle (separate from
+      hive-daemon):
+
+        install [--force] [--json]   write/register the per-user service unit
+        start                        start the managed hive-web service
+        stop                         stop the managed hive-web service
+        status [--json]              report managed service state
+
+      Local installs resolve the Rails app from HIVEBOX_WEB_APP_DIR, the
+      managed XDG data bundle (`hive setup` / AppBundle), or a source
+      checkout. Docker/hivebox continues to use /app/web via the env override.
+    DESC
+    option :bind, type: :string, desc: "override web.bind (foreground only)"
+    option :port, type: :numeric, desc: "override web.port (foreground only)"
+    option :force, type: :boolean, default: false, desc: "overwrite a drifted unit on install"
+    option :unsafe, type: :boolean, default: false,
+                    desc: "allow non-loopback bind without web.github.owner (foreground only)"
+    def web(subcommand = nil)
+      require "hive/commands/web"
+
+      if options[:json] && (subcommand.nil? || subcommand.empty? ||
+                            !Hive::Commands::Web::JSON_ALLOWED.include?(subcommand))
         require "json"
-        message = "hive web has no JSON output (it runs a long-lived server). " \
-                  "Use 'hive status --json' for machine-readable task data."
-        # Mirror `hive tui`'s rejection: emit a structured error envelope (sans
-        # `schema`, since web has no registered hive-* schema) and raise
-        # InvalidTaskPath for the USAGE (64) exit code — parity with every
-        # other --json failure on this surface.
+        message =
+          if subcommand.nil? || subcommand.empty?
+            "hive web has no JSON output (it runs a long-lived server). " \
+              "Use 'hive web status --json' or 'hive status --json'."
+          else
+            "hive web #{subcommand}: --json is only supported for install and status"
+          end
         puts JSON.generate(
           "ok" => false,
           "error_class" => "InvalidTaskPath",
@@ -1354,8 +1404,14 @@ module Hive
         raise Hive::InvalidTaskPath, message
       end
 
-      require "hive/commands/web"
-      Hive::Commands::Web.new(bind: options[:bind], port: options[:port]).call
+      Hive::Commands::Web.new(
+        subcommand: subcommand,
+        bind: options[:bind],
+        port: options[:port],
+        force: options[:force],
+        json: options[:json],
+        unsafe: options[:unsafe]
+      ).call
     end
 
     desc "tui", "Open the live, keystroke-driven dashboard for every active task"
diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb
index 6c91610b..dda9502c 100644
--- a/lib/hive/commands/daemon.rb
+++ b/lib/hive/commands/daemon.rb
@@ -60,7 +60,8 @@ module Hive
       def initialize(subcommand, target = nil, detach: false, dry_run: false,
                      all: false, json: false, force: false,
                      queue_args: [],
-                     hive_home: Hive::Paths.state_home)
+                     hive_home: Hive::Paths.state_home,
+                     emit: true)
         @subcommand = subcommand
         @target = target
         @detach = detach
@@ -70,6 +71,9 @@ module Hive
         @force = force
         @queue_args = Array(queue_args)
         @hive_home = hive_home
+        # Setup and other orchestrators need the mutation/result semantics
+        # without a nested command writing a second stdout document.
+        @emit = emit
       end
 
       def call
@@ -101,6 +105,17 @@ module Hive
 
       private
 
+      # Explicit non-emitting programmatic seam. Unqualified `puts`/`warn`
+      # throughout this command route here; normal CLI construction preserves
+      # the historical global streams, while orchestrators pass `emit: false`.
+      def puts(*lines)
+        $stdout.puts(*lines) if @emit
+      end
+
+      def warn(*lines)
+        $stderr.puts(*lines) if @emit
+      end
+
       def start_daemon
         warn_unsupported_json_flag if @json
         FileUtils.mkdir_p(@hive_home)
@@ -182,6 +197,10 @@ module Hive
         )
         supervisor = Hive::Daemon::ChildSupervisor.new(
           dry_run: @dry_run,
+          maintenance_binary_resolver: lambda {
+            require "hive/commands/daemon/service_installer"
+            Hive::Commands::Daemon::ServiceInstaller.new.resolved_binary_for_status
+          },
           default_timeout_sec: daemon_cfg.fetch(
             "child_timeout_sec", Hive::Config::DEFAULTS.dig("daemon", "child_timeout_sec")
           ),
@@ -355,6 +374,7 @@ module Hive
       end
 
       def status_daemon
+        require "hive/daemon/status_report"
         running = false
         pid = nil
         uptime_sec = nil
@@ -369,27 +389,25 @@ module Hive
           end
         end
 
+        report = Hive::Daemon::StatusReport.new(
+          hive_home: @hive_home,
+          binary_path: current_binary_path,
+          running_state: [ running, pid, uptime_sec ]
+        ).to_h
+
         if @json
-          service_state = probe_service_state
-          puts JSON.generate(
-            "schema" => "hive-daemon-status",
-            "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"),
-            "ok" => true,
-            "running" => running,
-            "pid" => running ? pid : nil,
-            "uptime_sec" => uptime_sec,
-            "pid_file" => pid_file,
-            "log_file" => log_file,
-            "service_installed" => service_state["service_installed"],
-            "service_enabled" => service_state["service_enabled"],
-            "unit_path" => service_state["unit_path"],
-            # Agent-native parity with the TUI footer / bot push: expose the
-            # update nudge so a programmatic caller can detect "behind" too.
-            "current_version" => Hive::VERSION,
-            "update_nudge" => update_nudge_payload
-          )
+          puts JSON.generate(report)
         elsif running
           puts "hive daemon: running (pid #{pid}, uptime #{uptime_sec}s)"
+          drift = report["binary_drift"]
+          unless drift == "none" || drift == "not_applicable"
+            puts "hive daemon: binary drift: #{drift}" \
+                 " (installed=#{report['installed_binary'].inspect}" \
+                 " expected=#{report['expected_binary'].inspect}" \
+                 " installed_version=#{report['installed_version'].inspect}" \
+                 " current_version=#{report['current_version'].inspect})"
+            puts "hive daemon: repair with `hive daemon install --force`"
+          end
         else
           puts "hive daemon: not running"
         end
@@ -397,29 +415,6 @@ module Hive
         raise Hive::Error, "daemon not running" unless running
       end
 
-      # Read-only autostart-state snapshot for the status envelope. A status
-      # probe must never take down the running/pid reporting that precedes
-      # it, so any failure degrades the three service fields to null (the
-      # status schema marks them required-but-nullable) instead of raising
-      # out of the whole command.
-      def probe_service_state
-        require "hive/commands/daemon/service_installer"
-        Hive::Commands::Daemon::ServiceInstaller.new.service_state
-      rescue StandardError
-        { "service_installed" => nil, "service_enabled" => nil, "unit_path" => nil }
-      end
-
-      # The daemon-written update nudge, as a plain Hash for the status
-      # envelope (nil when current or unknown). Never raises out of status.
-      def update_nudge_payload
-        nudge = Hive::UpdateCheck::State.new.nudge
-        return nil unless nudge
-
-        { "latest" => nudge.latest, "channel" => nudge.channel, "command" => nudge.command }
-      rescue StandardError
-        nil
-      end
-
       def reload_daemon
         result = compute_reload_outcome
         if @json
diff --git a/lib/hive/commands/daemon/queue_command.rb b/lib/hive/commands/daemon/queue_command.rb
index 66c041b9..7cb9b385 100644
--- a/lib/hive/commands/daemon/queue_command.rb
+++ b/lib/hive/commands/daemon/queue_command.rb
@@ -163,7 +163,9 @@ module Hive
             "chat_id" => req.chat_id,
             "update_id" => req.update_id,
             "expired" => Hive::Daemon::DispatchRequestQueue.expired?(req),
-            "allowlisted" => Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv)
+            "allowlisted" => Hive::Daemon::DispatchRequestQueue.valid_argv?(
+              req.argv, project: req.project, slug: req.slug
+            )
           }
         end
 
@@ -185,7 +187,9 @@ module Hive
           requests.each do |req|
             flags = []
             flags << "EXPIRED" if Hive::Daemon::DispatchRequestQueue.expired?(req)
-            flags << "NOT-ALLOWLISTED" unless Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv)
+            flags << "NOT-ALLOWLISTED" unless Hive::Daemon::DispatchRequestQueue.valid_argv?(
+              req.argv, project: req.project, slug: req.slug
+            )
             suffix = flags.empty? ? "" : " [#{flags.join(' ')}]"
             puts "#{req.request_id}  #{(Time.now - req.created_at).to_i}s  " \
                  "#{req.project}/#{req.slug}  #{req.argv[1]}#{suffix}"
@@ -205,7 +209,9 @@ module Hive
           puts "chat_id:    #{req.chat_id.inspect}"
           puts "update_id:  #{req.update_id.inspect}"
           puts "expired:    #{Hive::Daemon::DispatchRequestQueue.expired?(req)}"
-          puts "allowlisted:#{Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv)}"
+          puts "allowlisted:#{Hive::Daemon::DispatchRequestQueue.valid_argv?(
+            req.argv, project: req.project, slug: req.slug
+          )}"
         end
       end
     end
diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb
index 7a13eb00..94c3e3c9 100644
--- a/lib/hive/commands/init.rb
+++ b/lib/hive/commands/init.rb
@@ -75,14 +75,19 @@ module Hive
       CUSTOM_WORKFLOW_HINT_MESSAGE = "custom workflows live in this project — author one with `#{CUSTOM_WORKFLOW_HINT_COMMAND}`".freeze
 
       def initialize(project_path, force: false, json: false, prompts: nil,
-                     workflow: nil, new_workflow: nil, workflow_input: $stdin, workflow_output: $stderr)
+                     workflow: nil, new_workflow: nil, workflow_input: $stdin, workflow_output: $stderr,
+                     emit: true)
         @project_path = File.expand_path(project_path)
         @force = force
         @json = json
         @workflow_name = workflow
         @new_workflow = new_workflow
         @workflow_input = workflow_input
-        @workflow_output = workflow_output
+        # Programmatic callers such as `hive setup` compose Init's result into
+        # their own report. They can disable every Init-owned stdout/stderr
+        # emission without redirecting process-global streams.
+        @emit = emit
+        @workflow_output = @emit ? workflow_output : StringIO.new
         # Optional Prompts instance for testability. Tests inject a
         # pre-fed StringIO-backed instance to drive the interactive flow
         # without touching $stdin. Production keeps this nil so the
@@ -151,7 +156,7 @@ module Hive
         # collect_prompt_answers' contract: USAGE (64), not a generic
         # InternalError crash, and zero disk state (the prompt is BEFORE any
         # writes, per ADR-023).
-        warn "hive: aborted (#{e.message}); no changes made"
+        write_warn("hive: aborted (#{e.message}); no changes made")
         exit Hive::ExitCodes::USAGE
       rescue Hive::Error
         raise
@@ -279,6 +284,8 @@ module Hive
       end
 
       def write_existing_summary(_ops, workflow_choice:, scaffold_paths: nil)
+        return unless @emit
+
         name = File.basename(@project_path)
         $stdout.puts "hive: already initialized #{name}"
         $stdout.puts "workflow: #{workflow_choice.descriptor.id}"
@@ -297,6 +304,8 @@ module Hive
       # of the re-bind. Emit a minimal `already_initialized` hive-init payload
       # (a distinct oneOf arm from the fresh SuccessPayload in the schema).
       def emit_existing_json_summary(ops, workflow_choice:, extra: {})
+        return unless @emit
+
         puts JSON.generate(existing_payload(ops, workflow_choice: workflow_choice).merge(extra))
       rescue Errno::EPIPE
         nil
@@ -733,7 +742,9 @@ module Hive
       end
 
       def write_warn(line)
-        warn line
+        return unless @emit
+
+        $stderr.puts line
       rescue Errno::EPIPE
         nil
       end
@@ -777,6 +788,8 @@ module Hive
       end
 
       def emit_json_summary(entry:, ops:, answers:, workflow:, extra: {})
+        return unless @emit
+
         puts JSON.generate(success_payload(entry: entry, ops: ops, answers: answers, workflow: workflow).merge(extra))
       rescue Errno::EPIPE
         nil
@@ -867,6 +880,8 @@ module Hive
       end
 
       def print_summary(entry:, ops:, answers:, workflow: nil, scaffold_paths: nil)
+        return unless @emit
+
         c = Palette.for($stdout)
         name = entry["name"]
         rows = [
@@ -905,22 +920,27 @@ module Hive
       end
 
       def collect_prompt_answers
-        summary_io = @json ? StringIO.new : $stdout
-        prompts = @prompts || Hive::Commands::Init::Prompts.new(input: $stdin, output: $stderr, summary_io: summary_io)
+        summary_io = (@json || !@emit) ? StringIO.new : $stdout
+        prompt_output = @emit ? $stderr : StringIO.new
+        prompts = @prompts || Hive::Commands::Init::Prompts.new(
+          input: $stdin,
+          output: prompt_output,
+          summary_io: summary_io
+        )
         prompts.collect
       rescue Hive::Commands::Init::Prompts::Aborted => e
         # Distinct exit code (USAGE / 64) from generic crashes (GENERIC / 1)
         # so a scripted agent can tell "user explicitly declined" from
         # "init crashed transiently" and decide whether to retry. Closes
         # ce-code-review F6.
-        warn "hive: aborted (#{e.message}); no changes made"
+        write_warn("hive: aborted (#{e.message}); no changes made")
         exit Hive::ExitCodes::USAGE
       end
 
       def validate_git_repo!
         out, _err, status = Open3.capture3("git", "-C", @project_path, "rev-parse", "--git-common-dir")
         unless status.success?
-          warn "hive: not a git repository: #{@project_path}"
+          write_warn("hive: not a git repository: #{@project_path}")
           exit 1
         end
 
@@ -928,7 +948,7 @@ module Hive
         expected = File.join(@project_path, ".git")
         return if File.expand_path(common) == File.expand_path(expected)
 
-        warn "hive: target appears to be inside a worktree (common dir #{common}); init must run on the main checkout"
+        write_warn("hive: target appears to be inside a worktree (common dir #{common}); init must run on the main checkout")
         exit 1
       end
 
@@ -941,7 +961,7 @@ module Hive
         modified = out.lines.reject { |l| l.start_with?("??") }
         return if modified.empty?
 
-        warn "hive: uncommitted modifications to tracked files; commit or pass --force"
+        write_warn("hive: uncommitted modifications to tracked files; commit or pass --force")
         exit 1
       end
 
diff --git a/lib/hive/commands/service_installer/base.rb b/lib/hive/commands/service_installer/base.rb
index 04f9ba76..b87218aa 100644
--- a/lib/hive/commands/service_installer/base.rb
+++ b/lib/hive/commands/service_installer/base.rb
@@ -81,6 +81,35 @@ module Hive
           }
         end
 
+        # Expected binary path that install! would write into the unit.
+        # Public so StatusReport can compare without re-resolving PATH.
+        def resolved_binary_for_status
+          resolved_binary
+        end
+
+        # Parse the effective hive executable from the installed unit file.
+        # Returns nil when the unit is missing or the path cannot be parsed.
+        # Sets #binary_parse_error on parse failures.
+        attr_reader :binary_parse_error
+
+        def installed_binary_path
+          @binary_parse_error = nil
+          path = target_path
+          return nil if path.nil? || !File.exist?(path)
+
+          body = File.read(path)
+          case platform
+          when :linux then parse_systemd_exec_binary(body)
+          when :macos then parse_launchd_program_binary(body)
+          else
+            @binary_parse_error = "unsupported platform"
+            nil
+          end
+        rescue SystemCallError => e
+          @binary_parse_error = "unreadable: #{e.class}: #{e.message}"
+          nil
+        end
+
         # launchd plist Label for this service. Matches the `<key>Label</key>`
         # value in the bundled plists (local.hive-daemon / local.hive-bot).
         def launchd_label
@@ -404,6 +433,66 @@ module Hive
 
           !!which("launchctl")
         end
+
+        # systemd: `ExecStart=/path/to/hive daemon start` (possibly
+        # Shellwords-escaped). Extract the first token after ExecStart=.
+        def parse_systemd_exec_binary(body)
+          line = body.each_line.find { |l| l.start_with?("ExecStart=") }
+          unless line
+            @binary_parse_error = "no ExecStart= line"
+            return nil
+          end
+
+          value = line.sub(/\AExecStart=/, "").strip
+          # Drop systemd prefix modifiers like `@` / `-` if ever present.
+          value = value.sub(/\A[@+\-!:]+/, "")
+          tokens = Shellwords.split(value)
+          binary = tokens.first
+          if binary.nil? || binary.empty?
+            @binary_parse_error = "empty ExecStart binary"
+            return nil
+          end
+
+          binary
+        rescue ArgumentError => e
+          @binary_parse_error = "unparseable ExecStart: #{e.message}"
+          nil
+        end
+
+        # launchd: ProgramArguments array is
+        #   /bin/sh, -c, precheck, <hive-binary>, daemon|web|bot, ...
+        # The hive binary is the first absolute path that is not /bin/sh.
+        def parse_launchd_program_binary(body)
+          require "rexml/document"
+          doc = REXML::Document.new(body)
+          strings = []
+          doc.elements.each("//key") do |key|
+            next unless key.text.to_s.strip == "ProgramArguments"
+
+            array = key.next_element
+            next unless array && array.name == "array"
+
+            array.elements.each("string") { |s| strings << s.text.to_s }
+            break
+          end
+          if strings.empty?
+            @binary_parse_error = "no ProgramArguments"
+            return nil
+          end
+
+          # Precheck wrapper: /bin/sh -c '...' <binary> <subcommand>...
+          binary = strings.find { |s| s != "/bin/sh" && s != "-c" && s.include?("/") && !s.include?(" ") }
+          binary ||= strings.find { |s| File::SEPARATOR && s.start_with?("/") && s != "/bin/sh" }
+          if binary.nil?
+            @binary_parse_error = "could not locate hive binary in ProgramArguments"
+            return nil
+          end
+
+          binary
+        rescue REXML::ParseException, StandardError => e
+          @binary_parse_error = "unparseable plist: #{e.class}: #{e.message}"
+          nil
+        end
       end
     end
   end
diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb
new file mode 100644
index 00000000..da8201e3
--- /dev/null
+++ b/lib/hive/commands/setup.rb
@@ -0,0 +1,402 @@
+# frozen_string_literal: true
+
+require "json"
+require "open3"
+require "fileutils"
+require "net/http"
+require "timeout"
+require "uri"
+
+require "hive"
+require "hive/invoked_binary"
+require "hive/paths"
+require "hive/setup/diagnostics"
+require "hive/web/app_bundle"
+
+module Hive
+  module Commands
+    # `hive setup` — diagnose the host, bootstrap Hive-owned assets (qmd +
+    # Rails web bundle), install/start the daemon with the invoking binary,
+    # initialize or enroll the current repository, and optionally install
+    # the managed web service (`--service`).
+    #
+    # Phases always record outcomes so text, JSON `ok`, and process exit
+    # cannot disagree after a partial failure.
+    class Setup
+      SCHEMA = "hive-setup"
+      SCHEMA_VERSION = Hive::Schemas::SCHEMA_VERSIONS.fetch(SCHEMA)
+      QMD_PACKAGE = Hive::Setup::Diagnostics::QMD_PACKAGE
+      WEB_HEALTH_URL = "http://127.0.0.1:4567/health"
+      WEB_HEALTH_TIMEOUT = 15.0
+      WEB_HEALTH_ATTEMPT_TIMEOUT = 1.0
+      WEB_HEALTH_RETRY_INTERVAL = 0.1
+
+      Phase = Struct.new(:name, :ok, :detail, :error, keyword_init: true) do
+        def to_h
+          {
+            "name" => name,
+            "ok" => ok,
+            "detail" => detail,
+            "error" => error
+          }
+        end
+      end
+
+      def initialize(
+        project_path: Dir.pwd,
+        service: false,
+        no_bootstrap: false,
+        no_init: false,
+        json: false,
+        binary_path: nil,
+        diagnostics: nil,
+        app_bundle: nil,
+        qmd_installer: nil,
+        daemon_installer: nil,
+        web_installer: nil,
+        web_health_probe: nil,
+        web_health_timeout: WEB_HEALTH_TIMEOUT,
+        init_runner: nil,
+        enroll_runner: nil,
+        output: $stdout,
+        err: $stderr
+      )
+        @project_path = File.expand_path(project_path)
+        @service = service
+        @no_bootstrap = no_bootstrap
+        @no_init = no_init
+        @json = json
+        @binary_path = binary_path || Hive::InvokedBinary.path
+        @diagnostics = diagnostics
+        @app_bundle = app_bundle
+        @qmd_installer = qmd_installer || method(:default_install_qmd)
+        @daemon_installer = daemon_installer
+        @web_installer = web_installer
+        @web_health_probe = web_health_probe || method(:default_web_health_probe)
+        @web_health_timeout = Float(web_health_timeout)
+        @init_runner = init_runner || method(:default_init)
+        @enroll_runner = enroll_runner || method(:default_enroll)
+        @output = output
+        @err = err
+        @phases = []
+        @diag_report = nil
+        @web_bundle_refreshed = false
+      end
+
+      def call
+        @diag_report = run_diagnostics
+        unless @no_bootstrap
+          bootstrap_qmd if needs_qmd_bootstrap?
+          bootstrap_web_bundle if needs_web_bootstrap?
+          install_daemon
+          enroll_or_init unless @no_init
+          install_web_service if @service
+        end
+
+        overall_ok = overall_success?
+        emit_report(overall_ok)
+        raise Hive::Error, failure_summary unless overall_ok
+
+        overall_ok
+      end
+
+      attr_reader :phases, :diag_report
+
+      private
+
+      def run_diagnostics
+        report = (@diagnostics || Hive::Setup::Diagnostics.new).call
+        record_phase(
+          "diagnostics",
+          ok: report.ok,
+          detail: "#{report.results.count(&:ok?)}/#{report.results.length} checks ok",
+          error: report.ok ? nil : report.hard_failures.map { |r| "#{r.name}: #{r.fix || r.detail}" }.join("; ")
+        )
+        report
+      end
+
+      def needs_qmd_bootstrap?
+        row = @diag_report.results.find { |r| r.name == "qmd" }
+        row && row.bootstrappable
+      end
+
+      def needs_web_bootstrap?
+        row = @diag_report.results.find { |r| r.name == "web_bundle" }
+        row && row.bootstrappable
+      end
+
+      def bootstrap_qmd
+        begin
+          detail = @qmd_installer.call
+          record_phase("bootstrap_qmd", ok: true, detail: detail)
+        rescue StandardError => e
+          record_phase("bootstrap_qmd", ok: false, detail: nil, error: "#{e.class}: #{e.message}")
+        end
+      end
+
+      def bootstrap_web_bundle
+        begin
+          bundle = @app_bundle || Hive::Web::AppBundle.new
+          path = bundle.ensure_installed!
+          @web_bundle_refreshed = true
+          record_phase("bootstrap_web_bundle", ok: true, detail: path)
+        rescue StandardError => e
+          record_phase("bootstrap_web_bundle", ok: false, detail: nil, error: "#{e.class}: #{e.message}")
+        end
+      end
+
+      def install_daemon
+        begin
+          require "hive/commands/daemon/service_installer"
+          installer = @daemon_installer || Hive::Commands::Daemon::ServiceInstaller.new(
+            binary_path: @binary_path
+          )
+          result = installer.install!(autostart: true, force: true)
+          if result.success?
+            record_phase(
+              "daemon_install",
+              ok: true,
+              detail: "outcome=#{result.wire_outcome} unit=#{installer.target_path} binary=#{@binary_path}"
+            )
+          else
+            record_phase(
+              "daemon_install",
+              ok: false,
+              detail: result.wire_outcome,
+              error: installer.messages.last || "daemon install failed"
+            )
+          end
+        rescue StandardError => e
+          record_phase("daemon_install", ok: false, error: "#{e.class}: #{e.message}")
+        end
+      end
+
+      def enroll_or_init
+        initialized = project_initialized?
+        begin
+          if initialized
+            detail = @enroll_runner.call(@project_path)
+            record_phase("enroll", ok: true, detail: detail)
+          else
+            detail = @init_runner.call(@project_path)
+            record_phase("init", ok: true, detail: detail)
+          end
+        rescue SystemExit => e
+          name = initialized ? "enroll" : "init"
+          record_phase(name, ok: false, error: "SystemExit: exited with status #{e.status}")
+        rescue StandardError => e
+          name = initialized ? "enroll" : "init"
+          record_phase(name, ok: false, error: "#{e.class}: #{e.message}")
+        end
+      end
+
+      def install_web_service
+        begin
+          require "hive/commands/web/service_installer"
+          installer = @web_installer || Hive::Commands::Web::ServiceInstaller.new(
+            binary_path: @binary_path
+          )
+          active_before_install = @web_bundle_refreshed && !!installer.status_snapshot["service_active"]
+          # An already-active service is restarted explicitly below. Avoid an
+          # idempotent enable/load here (launchd rejects loading an already
+          # loaded plist, and systemd enable --now does not reload Rails code).
+          result = installer.install!(autostart: !active_before_install, force: true)
+          if result.success?
+            restarted = result.restarted
+            if active_before_install
+              restart_outcome = installer.restart!
+              unless restart_outcome == :ok
+                record_phase(
+                  "web_install",
+                  ok: false,
+                  detail: result.wire_outcome,
+                  error: installer.messages.last || "web service restart failed"
+                )
+                return
+              end
+              restarted = true
+            end
+            if active_before_install && !web_service_healthy?
+              record_phase(
+                "web_install",
+                ok: false,
+                detail: result.wire_outcome,
+                error: "health check failed at #{WEB_HEALTH_URL} within #{@web_health_timeout}s"
+              )
+              return
+            end
+            record_phase(
+              "web_install",
+              ok: true,
+              detail: "outcome=#{result.wire_outcome} unit=#{installer.target_path}" \
+                      " restarted=#{restarted} health_checked=#{active_before_install}"
+            )
+          else
+            record_phase(
+              "web_install",
+              ok: false,
+              detail: result.wire_outcome,
+              error: installer.messages.last || "web install failed"
+            )
+          end
+        rescue StandardError => e
+          record_phase("web_install", ok: false, error: "#{e.class}: #{e.message}")
+        end
+      end
+
+      def project_initialized?
+        File.directory?(File.join(@project_path, ".hive-state"))
+      end
+
+      def web_service_healthy?
+        Timeout.timeout(@web_health_timeout) do
+          @web_health_probe.call(WEB_HEALTH_URL, timeout: @web_health_timeout)
+        end
+      rescue Timeout::Error
+        false
+      end
+
+      def default_web_health_probe(url, timeout:)
+        uri = URI.parse(url)
+        deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+        loop do
+          remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
+          return false unless remaining.positive?
+
+          begin
+            attempt_timeout = [ remaining, WEB_HEALTH_ATTEMPT_TIMEOUT ].min
+            http = Net::HTTP.new(uri.host, uri.port)
+            http.open_timeout = attempt_timeout
+            http.read_timeout = attempt_timeout
+            http.write_timeout = attempt_timeout if http.respond_to?(:write_timeout=)
+            response = http.get(uri.request_uri)
+            return true if response.is_a?(Net::HTTPSuccess)
+          rescue IOError, SystemCallError, SocketError, Timeout::Error, Net::HTTPBadResponse
+            # Retry below until the shared deadline.
+          end
+
+          remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
+          return false unless remaining.positive?
+
+          sleep([ WEB_HEALTH_RETRY_INTERVAL, remaining ].min)
+        end
+      end
+
+      def record_phase(name, ok:, detail: nil, error: nil)
+        @phases << Phase.new(name: name, ok: ok, detail: detail, error: error)
+      end
+
+      def overall_success?
+        return false unless @diag_report&.ok
+        return true if @no_bootstrap
+
+        @phases.all?(&:ok)
+      end
+
+      def failure_summary
+        parts = []
+        if @diag_report && !@diag_report.ok
+          parts << "diagnostics failed: " + @diag_report.hard_failures.map { |r|
+            "#{r.name} (#{r.fix || r.detail})"
+          }.join("; ")
+        end
+        @phases.reject(&:ok).each do |phase|
+          parts << "#{phase.name}: #{phase.error || phase.detail}"
+        end
+        parts.empty? ? "setup failed" : parts.join(" | ")
+      end
+
+      def emit_report(overall_ok)
+        payload = {
+          "schema" => SCHEMA,
+          "schema_version" => SCHEMA_VERSION,
+          "ok" => overall_ok,
+          "service" => @service,
+          "no_bootstrap" => @no_bootstrap,
+          "no_init" => @no_init,
+          "binary_path" => @binary_path,
+          "project_path" => @project_path,
+          "web_url" => "http://127.0.0.1:4567",
+          "diagnostics" => @diag_report&.to_h,
+          "phases" => @phases.map(&:to_h)
+        }
+
+        if @json
+          @output.puts JSON.generate(payload)
+        else
+          emit_text(overall_ok, payload)
+        end
+      end
+
+      def emit_text(overall_ok, payload)
+        @output.puts "hive setup: #{overall_ok ? 'ok' : 'failed'}"
+        if @diag_report
+          @diag_report.results.each do |row|
+            mark = row.ok? ? "ok" : (row.bootstrappable ? "bootstrap" : "FAIL")
+            line = "  [#{mark}] #{row.name}: #{row.detail}"
+            line += "  → #{row.fix}" if row.fix
+            @output.puts line
+          end
+        end
+        @phases.each do |phase|
+          mark = phase.ok ? "ok" : "FAIL"
+          @output.puts "  phase #{phase.name}: #{mark}" \
+                       "#{phase.detail ? " — #{phase.detail}" : ""}" \
+                       "#{phase.error ? " — #{phase.error}" : ""}"
+        end
+        if overall_ok
+          if @service
+            @output.puts "hive setup: web UI at #{payload['web_url']}"
+          else
+            @output.puts "hive setup: run `hive web` in the foreground, or `hive setup --service` " \
+                         "to install the managed hive-web service"
+          end
+        else
+          @err.puts "hive setup: #{failure_summary}"
+        end
+      end
+
+      def default_install_qmd
+        prefix = File.join(Hive::Paths.data_home, "qmd")
+        FileUtils.mkdir_p(prefix)
+        cmd = [
+          "npm", "install", "--global",
+          "--prefix", prefix,
+          "--no-audit", "--no-fund",
+          QMD_PACKAGE
+        ]
+        out, err, status = Open3.capture3(*cmd)
+        raise Hive::Error, "qmd install failed: #{err.empty? ? out : err}" unless status.success?
+
+        "installed #{QMD_PACKAGE} under #{prefix}"
+      end
+
+      def default_init(project_path)
+        require "hive/commands/init"
+        # Non-interactive: pin the coding workflow; do not install daemon
+        # again (setup already did). Capture Init output.
+        Hive::Commands::Init.new(
+          project_path,
+          force: true,
+          json: true,
+          workflow: "coding",
+          workflow_input: StringIO.new("\n"),
+          workflow_output: StringIO.new,
+          emit: false
+        ).call
+        "initialized #{project_path}"
+      end
+
+      def default_enroll(project_path)
+        require "hive/commands/daemon"
+        name = File.basename(project_path)
+        # Ensure registry entry exists, then enable daemon for the project.
+        if Hive::Config.registered_projects.none? { |p| File.expand_path(p["path"].to_s) == project_path }
+          Hive::Config.register_project(name: name, path: project_path)
+        end
+        Hive::Commands::Daemon.new("enable", name, json: true, emit: false).call
+        "enrolled #{name}"
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb
index eb3cd40f..230bc1f6 100644
--- a/lib/hive/commands/web.rb
+++ b/lib/hive/commands/web.rb
@@ -1,27 +1,173 @@
+# frozen_string_literal: true
+
+require "json"
 require "hive/config"
+require "hive/invoked_binary"
 require "hive/web/session_secret"
+require "hive/web/app_bundle"
+require "hive/web/loopback"
 
 module Hive
   module Commands
-    # Boots the hivebox web UI — a Rails app living in web/ at the repo root
-    # (shipped in the Docker image at /app/web). hive itself stays a plain
-    # CLI gem; the web tier is only supported where the Rails app and its
-    # bundle exist: the hivebox container or a source checkout.
+    # Boots the Hive web UI — a Rails app living in `web/` (source checkout /
+    # Docker `/app/web`) or installed under XDG data via AppBundle for gem
+    # installs. Also manages the optional per-user `hive-web` service
+    # (systemd-user / launchd), independent of `hive-daemon`.
+    #
+    # Subcommands:
+    #   (none)   — foreground Rails server (always available)
+    #   install  — write/register the managed service unit
+    #   start    — start the managed service
+    #   stop     — stop the managed service
+    #   status   — report managed service state
     class Web
-      def initialize(bind: nil, port: nil)
+      VALID_SUBCOMMANDS = %w[install start stop status].freeze
+      JSON_ALLOWED = %w[install status].freeze
+
+      def initialize(subcommand: nil, bind: nil, port: nil, force: false, json: false,
+                     binary_path: nil, unsafe: false)
+        @subcommand = subcommand
         @bind = bind
         @port = port
+        @force = force
+        @json = json
+        @binary_path = binary_path
+        @unsafe = unsafe
       end
 
       def call
+        if @subcommand.nil? || @subcommand.empty?
+          run_foreground
+        elsif VALID_SUBCOMMANDS.include?(@subcommand)
+          public_send(:"#{@subcommand}_service")
+        else
+          raise Hive::InvalidTaskPath,
+                "hive web: unknown subcommand #{@subcommand.inspect} " \
+                "(expected: #{VALID_SUBCOMMANDS.join(', ')}, or bare `hive web` for foreground)"
+        end
+      end
+
+      def install_service
+        require "hive/commands/web/service_installer"
+        installer = service_installer
+        result = installer.install!(autostart: true, force: @force)
+        installer.messages.each { |line| warn "hive: #{line}" } unless @json
+        emit_install_outcome(installer, result)
+      end
+
+      def start_service
+        require "hive/commands/web/service_installer"
+        installer = service_installer
+        kind = installer.start!
+        installer.messages.each { |line| warn "hive: #{line}" } unless @json
+        case kind
+        when :ok
+          puts "hive web: service started" unless @json
+        when :autostart_unavailable
+          raise Hive::Error, "systemd-user not available; cannot start hive-web service"
+        when :unsupported
+          raise Hive::Error, "web service start is not supported on this platform"
+        else
+          raise Hive::Error, installer.messages.last || "hive web start failed"
+        end
+      end
+
+      def stop_service
+        require "hive/commands/web/service_installer"
+        installer = service_installer
+        kind = installer.stop!
+        installer.messages.each { |line| warn "hive: #{line}" } unless @json
+        case kind
+        when :ok
+          puts "hive web: service stopped" unless @json
+        when :autostart_unavailable
+          raise Hive::Error, "systemd-user not available; cannot stop hive-web service"
+        when :unsupported
+          raise Hive::Error, "web service stop is not supported on this platform"
+        else
+          raise Hive::Error, installer.messages.last || "hive web stop failed"
+        end
+      end
+
+      def status_service
+        require "hive/commands/web/service_installer"
+        installer = service_installer
+        snap = installer.status_snapshot
+        active = snap["service_active"] == true
+        payload = {
+          "schema" => "hive-web-status",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"),
+          "ok" => active,
+          "platform" => snap["platform"],
+          "service_installed" => snap["service_installed"],
+          "service_enabled" => snap["service_enabled"],
+          "service_active" => snap["service_active"],
+          "unit_path" => snap["unit_path"],
+          "error" => active ? nil : "web service not active"
+        }
+        if @json
+          puts JSON.generate(payload)
+        else
+          state =
+            if snap["service_active"]
+              "active"
+            elsif snap["service_installed"]
+              "installed (not active)"
+            else
+              "not installed"
+            end
+          puts "hive web: #{state}"
+          puts "  unit: #{snap['unit_path']}" if snap["unit_path"]
+        end
+        raise Hive::Error, "web service not active" unless active
+      end
+
+      # Gate non-loopback binds (public for unit tests).
+      def apply_bind_policy!(bind, cfg)
+        return if Hive::Web::Loopback.address?(bind)
+
+        owner = cfg.dig("github", "owner").to_s.strip
+        if !owner.empty?
+          warn "hive web: WARNING binding #{bind} (non-loopback) with configured owner " \
+               "#{owner.inspect} — GitHub login is required; protect this endpoint."
+          return
+        end
+
+        if @unsafe
+          warn "hive web: WARNING binding #{bind} (non-loopback) with --unsafe and no " \
+               "configured web.github.owner — existing auth/claim flow still applies; " \
+               "this is not safe for untrusted networks."
+          return
+        end
+
+        raise Hive::Error,
+              "hive web: refusing non-loopback bind #{bind.inspect} without " \
+              "web.github.owner or --unsafe. Default local bind is 127.0.0.1. " \
+              "Set web.github.owner, pass --unsafe, or bind a loopback address."
+      end
+
+      private
+
+      def run_foreground
+        if @json
+          raise Hive::InvalidTaskPath,
+                "hive web has no JSON output (it runs a long-lived server). " \
+                "Use 'hive web status --json' or 'hive status --json'."
+        end
+
         cfg = Hive::Config.load_global_web
         bind = @bind || cfg.fetch("bind")
         port = (@port || cfg.fetch("port")).to_i
+
+        # Bind/auth policy runs BEFORE bundle resolve / db:prepare so a
+        # refused public bind never mutates state or downloads assets.
+        apply_bind_policy!(bind, cfg)
+
         app_dir = rails_app_dir
         unless app_dir
-          warn "hive web: the hivebox web app (web/) was not found. " \
-               "Run from the hivebox Docker image or a source checkout, " \
-               "or point HIVEBOX_WEB_APP_DIR at the Rails app."
+          warn "hive web: the Rails web app was not found. " \
+               "Run `hive setup` to install the matching web bundle, use a source " \
+               "checkout, the hivebox Docker image, or set HIVEBOX_WEB_APP_DIR."
           exit 1
         end
 
@@ -29,53 +175,42 @@ module Hive
 
         env = {
           "RAILS_ENV" => ENV.fetch("RAILS_ENV", "production"),
-          # Rails' secret_key_base derives from the same persisted secret the
-          # session cookies used pre-Rails, so recreating the container keeps
-          # sessions (the file lives on the /data mount).
           "SECRET_KEY_BASE" => ENV["SECRET_KEY_BASE"] ||
             Hive::Web::SessionSecret.load_or_create(cfg.fetch("session_secret_file")),
           "HIVEBOX_ORIGIN" => cfg.fetch("origin"),
-          # The solid_cable/cache/queue sqlite files must survive image
-          # upgrades — keep them in state_home (on /data in the container),
-          # not in the app dir.
           "HIVEBOX_STORAGE_DIR" => ENV["HIVEBOX_STORAGE_DIR"] ||
             File.join(Hive::Paths.state_home, "web-storage"),
           "BUNDLE_GEMFILE" => File.join(app_dir, "Gemfile")
-        }
+        }.merge(@app_bundle.runtime_env(app_dir))
+        # Signal loopback no-auth mode only when bind is loopback and
+        # config allows it. Rails still re-checks request.remote_ip.
+        if Hive::Web::Loopback.address?(bind) && cfg.fetch("local_loopback", true)
+          env[Hive::Web::Loopback::ENV_MODE] = "1"
+        else
+          env.delete(Hive::Web::Loopback::ENV_MODE)
+        end
         FileUtils.mkdir_p(env.fetch("HIVEBOX_STORAGE_DIR"))
 
         Dir.chdir(app_dir) do
-          # Idempotent: creates/migrates the solid-stack sqlite databases on
-          # first boot, no-ops afterwards. Array form — no shell involved.
-          # Typed error so a persistent failure surfaces as guidance, not a
-          # raw backtrace looping every 5s under the container supervisor.
           unless system(env, "bin/rails", "db:prepare")
             raise Hive::Error,
                   "hive web: db:prepare failed — check that " \
-                  "#{env.fetch("HIVEBOX_STORAGE_DIR")} is writable (the /data mount) " \
+                  "#{env.fetch("HIVEBOX_STORAGE_DIR")} is writable " \
                   "and that the web bundle is installed (cd #{app_dir} && bundle install)"
           end
           puts "hive web: listening on http://#{bind}:#{port}"
-          # Replace this process with the Rails server (array form, env hash;
-          # Kernel#exec never touches a shell when given an argv list).
           Kernel.exec env, "bin/rails", "server", "-b", bind, "-p", port.to_s
         end
       end
 
-      private
-
       def rails_app_dir
-        candidates = [
-          ENV["HIVEBOX_WEB_APP_DIR"],
-          File.expand_path("../../../web", __dir__)
-        ].compact
-        candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) }
+        @app_bundle = Hive::Web::AppBundle.new
+        @app_bundle.resolve!
+      rescue Hive::Web::AppBundle::Error => e
+        warn "hive web: #{e.message}"
+        nil
       end
 
-      # Rails' production host authorization is inactive by default — the box
-      # assumes a trusted reverse proxy validates Host, exactly like the
-      # pre-Rails posture. Binding a public interface without that proxy
-      # exposes the app to DNS-rebinding / Host-injection, so make it loud.
       def warn_on_public_bind(bind, cfg)
         return unless bind.to_s == "0.0.0.0"
         return if cfg["origin"].to_s.start_with?("https://")
@@ -83,6 +218,61 @@ module Hive
         warn "hive web: WARNING binding 0.0.0.0 without an https origin — " \
              "ensure a trusted reverse proxy validates the Host header."
       end
+
+      def service_installer
+        require "hive/commands/web/service_installer"
+        Hive::Commands::Web::ServiceInstaller.new(
+          binary_path: @binary_path || Hive::InvokedBinary.path
+        )
+      end
+
+      def emit_install_outcome(installer, result)
+        if @json
+          if result.success?
+            puts JSON.generate(
+              "schema" => "hive-web-install",
+              "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"),
+              "ok" => true,
+              "outcome" => result.wire_outcome,
+              "platform" => installer.envelope_platform,
+              "unit_path" => installer.target_path,
+              "backup_path" => result.backup_path,
+              "restarted" => result.restarted,
+              "messages" => installer.messages
+            )
+          else
+            puts JSON.generate(
+              "schema" => "hive-web-install",
+              "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"),
+              "ok" => false,
+              "outcome" => result.wire_outcome,
+              "platform" => installer.envelope_platform,
+              "unit_path" => installer.target_path,
+              "messages" => installer.messages,
+              "error" => installer.messages.last || "web service install failed"
+            )
+            raise Hive::Error, installer.messages.last || "web service install failed"
+          end
+          return
+        end
+
+        case result.kind
+        when :written
+          puts "hive web: installed unit at #{installer.target_path}"
+        when :upgraded
+          puts "hive web: upgraded unit at #{installer.target_path}"
+        when :unchanged
+          puts "hive web: unit already up to date at #{installer.target_path}"
+        when :autostart_unavailable
+          puts "hive web: unit written at #{installer.target_path}; autostart not enabled on this host"
+        when :drifted
+          raise Hive::Error, installer.messages.last || "web unit drifted; re-run with --force"
+        when :failed
+          raise Hive::Error, installer.messages.last || "web service install failed"
+        when :unsupported
+          # message already warned
+        end
+      end
     end
   end
 end
diff --git a/lib/hive/commands/web/service_installer.rb b/lib/hive/commands/web/service_installer.rb
new file mode 100644
index 00000000..e1b13528
--- /dev/null
+++ b/lib/hive/commands/web/service_installer.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+require "cgi"
+require "shellwords"
+require "hive/commands/service_installer/base"
+
+module Hive
+  module Commands
+    class Web
+      # Per-user autostart installer for the local Rails web UI. Independent
+      # of hive-daemon / hive-bot — separate unit, separate lifecycle.
+      class ServiceInstaller < Hive::Commands::ServiceInstaller::Base
+        def service_name
+          "hive-web"
+        end
+
+        def cli_label
+          "web"
+        end
+
+        def service_noun
+          "web service"
+        end
+
+        def unit_noun
+          "web unit"
+        end
+
+        def target_path
+          case platform
+          when :macos then File.join(@home, "Library/LaunchAgents/local.hive-web.plist")
+          when :linux then File.join(@home, ".config/systemd/user/hive-web.service")
+          end
+        end
+
+        # Start the installed unit via the native service manager.
+        # Returns :ok, :failed, :autostart_unavailable, or :unsupported.
+        def start!
+          case platform
+          when :linux
+            return :autostart_unavailable unless systemctl_available?
+
+            ok = @runner.call([ "systemctl", "--user", "start", service_name ])
+            unless ok
+              @messages << "systemctl --user start #{service_name} failed; " \
+                           "run `systemctl --user status #{service_name}` for details"
+              return :failed
+            end
+            :ok
+          when :macos
+            path = target_path
+            unless path && File.exist?(path)
+              @messages << "web unit not installed at #{path}; run `hive web install` first"
+              return :failed
+            end
+            ok = @runner.call([ "launchctl", "load", path ])
+            unless ok
+              @messages << "launchctl load failed for #{path}"
+              return :failed
+            end
+            :ok
+          else
+            @messages << "web service start is not supported on this platform"
+            :unsupported
+          end
+        end
+
+        # Restart the installed unit so a running Rails process loads a newly
+        # refreshed managed app bundle even when the service definition itself
+        # is unchanged.
+        def restart!
+          case platform
+          when :linux
+            return :autostart_unavailable unless systemctl_available?
+
+            reloaded = @runner.call(%w[systemctl --user daemon-reload])
+            restarted = @runner.call([ "systemctl", "--user", "restart", service_name ])
+            unless reloaded && restarted
+              @messages << "systemctl --user restart #{service_name} failed; " \
+                           "run `systemctl --user status #{service_name}` for details"
+              return :failed
+            end
+            :ok
+          when :macos
+            path = target_path
+            unless path && File.exist?(path)
+              @messages << "web unit not installed at #{path}; run `hive web install` first"
+              return :failed
+            end
+            unless @runner.call([ "launchctl", "unload", path ])
+              @messages << "launchctl unload failed for #{path}"
+              return :failed
+            end
+            unless @runner.call([ "launchctl", "load", path ])
+              @messages << "launchctl load failed for #{path}"
+              return :failed
+            end
+            :ok
+          else
+            @messages << "web service restart is not supported on this platform"
+            :unsupported
+          end
+        end
+
+        # Stop the installed unit via the native service manager.
+        def stop!
+          case platform
+          when :linux
+            return :autostart_unavailable unless systemctl_available?
+
+            ok = @runner.call([ "systemctl", "--user", "stop", service_name ])
+            unless ok
+              @messages << "systemctl --user stop #{service_name} failed"
+              return :failed
+            end
+            :ok
+          when :macos
+            path = target_path
+            unless path && File.exist?(path)
+              @messages << "web unit not installed at #{path}"
+              return :failed
+            end
+            ok = @runner.call([ "launchctl", "unload", path ])
+            unless ok
+              @messages << "launchctl unload failed for #{path} (benign if not loaded)"
+              return :failed
+            end
+            :ok
+          else
+            @messages << "web service stop is not supported on this platform"
+            :unsupported
+          end
+        end
+
+        # Non-mutating status snapshot including whether the manager reports
+        # the job as active/loaded.
+        def status_snapshot
+          state = service_state
+          state.merge(
+            "service_active" => service_active?
+          )
+        end
+
+        private
+
+        def service_active?
+          case platform
+          when :linux
+            return false unless systemctl_available?
+
+            !!@runner.call([ "systemctl", "--user", "is-active", service_name ])
+          when :macos
+            return false unless launchctl_available?
+
+            !!@runner.call([ "launchctl", "list", launchd_label ])
+          else
+            false
+          end
+        end
+
+        def render_systemd
+          template = File.read(File.expand_path("../../../../examples/systemd/hive-web.service", __dir__))
+          escaped = Shellwords.escape(resolved_binary)
+          template
+            .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} web")
+            .sub(/^Environment=PATH=.*$/, build_path_line)
+        end
+
+        def render_launchd
+          template = File.read(File.expand_path("../../../../examples/launchd/hive-web.plist", __dir__))
+          binary = resolved_binary
+          binary_dir = File.dirname(binary)
+          escaped_binary = CGI.escapeHTML(binary)
+          escaped_binary_dir = CGI.escapeHTML(binary_dir)
+          escaped_home = CGI.escapeHTML(@home)
+          template
+            .gsub(%r{<string>/Users/YOU/\.local/bin/hive</string>}, "<string>#{escaped_binary}</string>")
+            .gsub("/Users/YOU/Library/Logs", "#{escaped_home}/Library/Logs")
+            .gsub("/Users/YOU/.local/bin", escaped_binary_dir)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/config.rb b/lib/hive/config.rb
index da8bd31a..3dd88d91 100644
--- a/lib/hive/config.rb
+++ b/lib/hive/config.rb
@@ -373,6 +373,10 @@ module Hive
         "bind" => "127.0.0.1",
         "port" => 4567,
         "origin" => "http://127.0.0.1:4567",
+        # Local single-user mode: when the bind is loopback, genuine
+        # loopback peers may skip GitHub login. Opt out with false.
+        # Non-loopback binds never use this bypass (see Loopback + Commands::Web).
+        "local_loopback" => true,
         "github" => {
           "owner" => nil,
           # The shared hivebox OAuth app (device flow only — public by
@@ -2291,6 +2295,13 @@ module Hive
               "web.github.#{key} in #{describe_source(source_path)} must be a non-empty String when set"
       end
 
+      local_loopback = web["local_loopback"]
+      unless [ true, false ].include?(local_loopback)
+        raise ConfigError,
+              "web.local_loopback in #{describe_source(source_path)} must be true or false; " \
+              "got #{local_loopback.inspect}"
+      end
+
       secret_file = web["session_secret_file"]
       return if secret_file.nil? || (secret_file.is_a?(String) && !secret_file.strip.empty?)
 
diff --git a/lib/hive/daemon/child_supervisor.rb b/lib/hive/daemon/child_supervisor.rb
index 1b658816..7ee9fc1d 100644
--- a/lib/hive/daemon/child_supervisor.rb
+++ b/lib/hive/daemon/child_supervisor.rb
@@ -3,6 +3,7 @@ require "json"
 require "shellwords"
 require "time"
 require "tmpdir"
+require "hive/daemon/dispatch_request_queue"
 
 module Hive
   module Daemon
@@ -34,12 +35,14 @@ module Hive
       DEFAULT_KILL_GRACE_SEC = 30
 
       def initialize(hive_bin: ENV.fetch("HIVE_BIN", "hive"),
+                     maintenance_binary_resolver: nil,
                      log_dir_for_task: nil,
                      dry_run: false,
                      default_timeout_sec: 0,
                      verb_timeouts: {},
                      kill_grace_sec: DEFAULT_KILL_GRACE_SEC)
         @hive_bin = hive_bin
+        @maintenance_binary_resolver = maintenance_binary_resolver || -> { @hive_bin }
         @dry_run = dry_run
         # Optional injection point: tests pass a lambda taking (project, slug)
         # → an absolute path to write the child's combined stdout/stderr.
@@ -103,9 +106,14 @@ module Hive
           # An unexpected first token is a dispatcher classifier bug.
           raise ArgumentError, "ChildSupervisor refuses non-hive command: #{command_string.inspect}"
         end
-        # Replace literal "hive" with the configured binary so tests
-        # can swap in a fixture path via HIVE_BIN.
-        argv[0] = @hive_bin
+        # Ordinary children keep using the daemon's configured HIVE_BIN.
+        # The reserved repair request is different: its purpose is to
+        # replace that potentially stale service binary, so resolve the
+        # current install candidate at dispatch time.
+        maintenance = Hive::Daemon::DispatchRequestQueue.maintenance_request?(
+          project: project, slug: slug, argv: argv
+        )
+        argv[0] = maintenance ? resolved_maintenance_binary : @hive_bin
 
         timeout_sec = timeout_for_verb(argv_verb(argv))
 
@@ -269,6 +277,13 @@ module Hive
 
       private
 
+      def resolved_maintenance_binary
+        binary = @maintenance_binary_resolver.call
+        return binary unless binary.to_s.empty?
+
+        raise ArgumentError, "ChildSupervisor could not resolve current hive binary for daemon repair"
+      end
+
       def parse_command(command_string)
         Shellwords.split(command_string)
       end
diff --git a/lib/hive/daemon/dispatch_request_queue.rb b/lib/hive/daemon/dispatch_request_queue.rb
index 00d7aacc..4bffd997 100644
--- a/lib/hive/daemon/dispatch_request_queue.rb
+++ b/lib/hive/daemon/dispatch_request_queue.rb
@@ -20,6 +20,14 @@ module Hive
         archive markers
       ].freeze
 
+      # Reserved global project for host-maintenance requests (daemon repair).
+      # Not a real enrolled project; dispatcher bypasses enrollment only for
+      # this sentinel + exact allowlisted argv.
+      GLOBAL_PROJECT = "__hive_host__".freeze
+      GLOBAL_SLUG = "host-maintenance".freeze
+      # Exact argv only — no other global/daemon argv may be enqueued.
+      MAINTENANCE_ARGV = %w[hive daemon install --force].freeze
+
       DIRNAME = "dispatch_requests".freeze
       CLAIMED_SUFFIX = ".claimed".freeze
       CLAIM_META_SUFFIX = ".claim".freeze
@@ -51,7 +59,7 @@ module Hive
       def write_request!(project:, slug:, argv:, requestor: "bot", chat_id: nil,
                          update_id: nil, trigger: nil, request_id: generate_request_id,
                          state_home: Hive::Paths.state_home, now: Time.now)
-        unless valid_argv?(argv)
+        unless valid_argv?(argv, project: project, slug: slug)
           raise ArgumentError, "argv #{argv.inspect} is not allowlisted for dispatch requests"
         end
         raise ArgumentError, "project is required for dispatch requests" if project.to_s.empty?
@@ -339,12 +347,17 @@ module Hive
         )
       end
 
-      def valid_argv?(argv)
+      def valid_argv?(argv, project: nil, slug: nil)
         return false unless argv.is_a?(Array)
         return false if argv.length < 2
         return false unless argv.all? { |tok| tok.is_a?(String) && !tok.empty? }
         return false unless argv[0] == "hive"
 
+        # Host maintenance is valid only for the complete reserved tuple.
+        # Keeping this check contextual prevents an exact maintenance argv
+        # from riding an enabled real project's ordinary dispatch gates.
+        return maintenance_request?(project: project, slug: slug, argv: argv) if argv == MAINTENANCE_ARGV
+
         verb = argv[1].to_s
         return false unless ALLOWED_VERBS.include?(verb)
         return true if argv.length < 3
@@ -354,6 +367,12 @@ module Hive
         SLUG_RE.match?(argv[2])
       end
 
+      def maintenance_request?(project:, slug:, argv:)
+        project.to_s == GLOBAL_PROJECT &&
+          slug.to_s == GLOBAL_SLUG &&
+          argv == MAINTENANCE_ARGV
+      end
+
       def filename_for(created_at:, request_id:)
         ts = created_at.utc.strftime("%Y%m%dT%H%M%S%6N")
         "#{ts}-#{request_id}.json"
diff --git a/lib/hive/daemon/dispatcher.rb b/lib/hive/daemon/dispatcher.rb
index 1960febf..b9da1a50 100644
--- a/lib/hive/daemon/dispatcher.rb
+++ b/lib/hive/daemon/dispatcher.rb
@@ -1326,7 +1326,11 @@ module Hive
       # checks AND the actual spawn. Returns nil; side effects via
       # @controller, @logger, and the queue's remove() call.
       def process_dispatch_request_iteration(req, now:)
-        unless Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv)
+        unless Hive::Daemon::DispatchRequestQueue.valid_argv?(
+          req.argv,
+          project: req.project,
+          slug: req.slug
+        )
           reject_request(req, reason: "invalid_argv")
           return
         end
@@ -1336,27 +1340,34 @@ module Hive
           return
         end
 
-        unless Hive::Config.find_project(req.project)
-          reject_request(req, reason: "unknown_project")
-          return
-        end
+        maintenance = Hive::Daemon::DispatchRequestQueue.maintenance_request?(
+          project: req.project, slug: req.slug, argv: req.argv
+        )
 
-        # C4 from PR #241 ce-code-review: gate on project_enabled? so a
-        # disabled project's queued requests don't dispatch. The
-        # auto-advance path (handle_row) already does this; the
-        # request path must mirror to keep the single-dispatcher
-        # invariant honest.
-        unless project_enabled?(req.project)
-          @logger.event(:dispatch_request_blocked,
-                        request_id: req.request_id, project: req.project,
-                        slug: req.slug, reason: "project_disabled")
-          return
+        unless maintenance
+          unless Hive::Config.find_project(req.project)
+            reject_request(req, reason: "unknown_project")
+            return
+          end
+
+          # C4 from PR #241 ce-code-review: gate on project_enabled? so a
+          # disabled project's queued requests don't dispatch. The
+          # auto-advance path (handle_row) already does this; the
+          # request path must mirror to keep the single-dispatcher
+          # invariant honest.
+          unless project_enabled?(req.project)
+            @logger.event(:dispatch_request_blocked,
+                          request_id: req.request_id, project: req.project,
+                          slug: req.slug, reason: "project_disabled")
+            return
+          end
         end
 
         # Reverse the gate-evaluation order from the previous version:
         # check the cheap, deterministic running_task? gate first so an
         # in-flight slug doesn't incur the can_dispatch? scan. Per R-04
-        # / M-05 from PR #241 ce-code-review.
+        # / M-05 from PR #241 ce-code-review. Also dedupes concurrent
+        # host-maintenance repair requests under the same sentinel slug.
         if @controller.running_task?(project: req.project, slug: req.slug)
           @logger.event(:dispatch_request_blocked,
                         request_id: req.request_id, project: req.project,
@@ -1364,16 +1375,18 @@ module Hive
           return
         end
 
-        gate = @controller.can_dispatch?(
-          project: req.project, slug: req.slug, now: now,
-          external_global_count: @external_active_agent_total,
-          external_project_count: external_active_agent_count_for(req.project)
-        )
-        unless gate == :ok
-          @logger.event(:dispatch_request_blocked,
-                        request_id: req.request_id, project: req.project,
-                        slug: req.slug, reason: gate.to_s)
-          return
+        unless maintenance
+          gate = @controller.can_dispatch?(
+            project: req.project, slug: req.slug, now: now,
+            external_global_count: @external_active_agent_total,
+            external_project_count: external_active_agent_count_for(req.project)
+          )
+          unless gate == :ok
+            @logger.event(:dispatch_request_blocked,
+                          request_id: req.request_id, project: req.project,
+                          slug: req.slug, reason: gate.to_s)
+            return
+          end
         end
 
         dispatch_request!(req, now: now)
diff --git a/lib/hive/daemon/status_report.rb b/lib/hive/daemon/status_report.rb
new file mode 100644
index 00000000..3435dea4
--- /dev/null
+++ b/lib/hive/daemon/status_report.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require "hive"
+require "hive/bounded_subprocess"
+require "hive/paths"
+require "hive/pid_file"
+require "hive/update_check/state"
+
+module Hive
+  module Daemon
+    # Shared producer of the `hive-daemon-status` payload for CLI and web.
+    # Returns a plain Hash — never captures `$stdout` — so threaded Puma
+    # can call it safely. Binary version probes are bounded.
+    class StatusReport
+      include Hive::PidFile
+
+      VERSION_PROBE_TIMEOUT_SEC = 2
+      DRIFT_NONE = "none"
+      DRIFT_PATH = "path"
+      DRIFT_VERSION = "version"
+      DRIFT_UNPARSEABLE = "unparseable"
+      DRIFT_UNREADABLE = "unreadable"
+      DRIFT_NOT_APPLICABLE = "not_applicable"
+      DRIFT_VALUES = [
+        DRIFT_NONE, DRIFT_PATH, DRIFT_VERSION, DRIFT_UNPARSEABLE,
+        DRIFT_UNREADABLE, DRIFT_NOT_APPLICABLE
+      ].freeze
+
+      def initialize(
+        hive_home: Hive::Paths.state_home,
+        binary_path: nil,
+        installer: nil,
+        version_probe: nil,
+        update_state: nil,
+        running_state: nil
+      )
+        @hive_home = hive_home
+        @binary_path = binary_path
+        @installer = installer
+        @version_probe = version_probe || method(:default_version_probe)
+        @update_state = update_state
+        # Optional override: [running, pid, uptime_sec]. Used by Commands::Daemon
+        # so existing PID ownership stubs on the command still apply.
+        @running_state_override = running_state
+      end
+
+      def to_h
+        running, pid, uptime_sec = @running_state_override || detect_running_state
+        service = probe_service_state
+        drift = compute_drift(service)
+        {
+          "schema" => "hive-daemon-status",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"),
+          "ok" => true,
+          "running" => running,
+          "pid" => running ? pid : nil,
+          "uptime_sec" => uptime_sec,
+          "pid_file" => pid_file,
+          "log_file" => log_file,
+          "service_installed" => service["service_installed"],
+          "service_enabled" => service["service_enabled"],
+          "unit_path" => service["unit_path"],
+          "current_version" => Hive::VERSION,
+          "update_nudge" => update_nudge_payload,
+          "expected_binary" => service["expected_binary"],
+          "installed_binary" => service["installed_binary"],
+          "installed_version" => service["installed_version"],
+          "binary_drift" => drift
+        }
+      end
+
+      # Subset safe for dashboard rendering (no filesystem secrets beyond
+      # already-public status paths).
+      def safe_payload
+        to_h
+      end
+
+      private
+
+      def pid_file
+        File.join(@hive_home, ".daemon.pid")
+      end
+
+      def log_file
+        File.join(@hive_home, "logs", "daemon.log")
+      end
+
+      def detect_running_state
+        pid = read_live_pid
+        return [ false, nil, nil ] unless pid
+
+        uptime = (Time.now - File.stat(pid_file).mtime).to_i
+        [ true, pid, uptime ]
+      rescue StandardError
+        [ false, nil, nil ]
+      end
+
+      def probe_service_state
+        installer = resolve_installer
+        state = installer.service_state
+        expected = installer.resolved_binary_for_status
+        installed = installer.installed_binary_path
+        installed_version = nil
+        version_probe_failed = false
+        if installed && File.executable?(installed)
+          installed_version = probe_installed_version(installed)
+          version_probe_failed = installed_version.nil?
+        elsif installed
+          version_probe_failed = true
+        end
+        state.merge(
+          "expected_binary" => expected,
+          "installed_binary" => installed,
+          "installed_version" => installed_version,
+          "version_probe_failed" => version_probe_failed,
+          "binary_parse_error" => installer.binary_parse_error
+        )
+      rescue StandardError => e
+        {
+          "service_installed" => nil,
+          "service_enabled" => nil,
+          "unit_path" => nil,
+          "expected_binary" => nil,
+          "installed_binary" => nil,
+          "installed_version" => nil,
+          "version_probe_failed" => false,
+          "binary_parse_error" => "unreadable: #{e.class}: #{e.message}"
+        }
+      end
+
+      def resolve_installer
+        return @installer if @installer
+
+        require "hive/commands/daemon/service_installer"
+        Hive::Commands::Daemon::ServiceInstaller.new(binary_path: @binary_path)
+      end
+
+      def compute_drift(service)
+        if service["binary_parse_error"].to_s.start_with?("unreadable")
+          return DRIFT_UNREADABLE
+        end
+
+        unit_path = service["unit_path"]
+        installed_flag = service["service_installed"]
+        if unit_path.nil? && installed_flag.nil?
+          return DRIFT_UNREADABLE
+        end
+        if installed_flag == false || unit_path.nil?
+          return DRIFT_NOT_APPLICABLE
+        end
+
+        installed = service["installed_binary"]
+        expected = service["expected_binary"]
+        if installed.nil?
+          return service["binary_parse_error"] ? DRIFT_UNPARSEABLE : DRIFT_UNREADABLE
+        end
+
+        return DRIFT_UNREADABLE if service["version_probe_failed"]
+
+        if expected && paths_differ?(installed, expected)
+          return DRIFT_PATH
+        end
+
+        installed_version = service["installed_version"]
+        if installed_version && installed_version != Hive::VERSION
+          return DRIFT_VERSION
+        end
+
+        DRIFT_NONE
+      end
+
+      def paths_differ?(a, b)
+        File.expand_path(a.to_s) != File.expand_path(b.to_s)
+      rescue StandardError
+        a.to_s != b.to_s
+      end
+
+      def update_nudge_payload
+        state = @update_state || Hive::UpdateCheck::State.new
+        nudge = state.nudge
+        return nil unless nudge
+
+        { "latest" => nudge.latest, "channel" => nudge.channel, "command" => nudge.command }
+      rescue StandardError
+        nil
+      end
+
+      def default_version_probe(binary)
+        out, err, status = Hive::BoundedSubprocess.capture3(
+          binary,
+          "--version",
+          timeout: VERSION_PROBE_TIMEOUT_SEC
+        )
+        return nil unless status.success?
+
+        text = "#{out}\n#{err}"
+        # hive --version prints "hive 0.3.2" or just "0.3.2"
+        match = text.match(/(\d+\.\d+\.\d+)/)
+        match && match[1]
+      rescue Hive::BoundedSubprocess::TimeoutError,
+             Errno::ENOENT, Errno::EACCES, SystemCallError
+        nil
+      end
+
+      def probe_installed_version(binary)
+        version = @version_probe.call(binary)
+        return version if version.to_s.match?(/\A\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/)
+
+        nil
+      rescue StandardError
+        nil
+      end
+    end
+  end
+end
diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb
index 752a5c7d..0bb75dee 100644
--- a/lib/hive/paths.rb
+++ b/lib/hive/paths.rb
@@ -20,6 +20,18 @@ module Hive
       hive_home_override || File.join(base_home("XDG_CACHE_HOME", ".cache"), "hive")
     end
 
+    # Managed Rails app tree installed by `Hive::Web::AppBundle` for local
+    # (non-Docker) web mode. Lives under XDG data — not state — because the
+    # app is a version-matched release asset, not mutable runtime data.
+    # SQLite/session storage stays under state_home (see Commands::Web).
+    def web_app_home
+      File.join(data_home, "web-app")
+    end
+
+    def web_app_version_stamp_path
+      File.join(web_app_home, ".hive-web-version")
+    end
+
     def task_counter_path
       File.join(state_home, "task-counter.yml")
     end
diff --git a/lib/hive/setup/diagnostics.rb b/lib/hive/setup/diagnostics.rb
new file mode 100644
index 00000000..db4b6b76
--- /dev/null
+++ b/lib/hive/setup/diagnostics.rb
@@ -0,0 +1,539 @@
+# frozen_string_literal: true
+
+require "json"
+require "rbconfig"
+
+require "hive"
+require "hive/agent_profiles"
+require "hive/bounded_subprocess"
+require "hive/claude_launcher"
+require "hive/paths"
+require "hive/web/app_bundle"
+
+module Hive
+  module Setup
+    # Bounded preflight for `hive setup`. Each dependency becomes a Result
+    # with status, detail, an exact fix command (when applicable), and a
+    # `bootstrappable` flag. Only Hive-owned assets (qmd, the Rails web
+    # bundle) may be bootstrapped; external CLIs (gh/claude/codex) are never
+    # installed or authenticated by setup.
+    class Diagnostics
+      PROBE_TIMEOUT_SEC = 5
+      MIN_RUBY = "3.4.0"
+      MIN_TMUX = Hive::ClaudeLauncher::MIN_TMUX_VERSION
+      QMD_PACKAGE = "@tobilu/qmd"
+
+      STATUSES = %w[
+        ok
+        missing
+        outdated
+        unauthenticated
+        bootstrappable
+        error
+        unsupported
+      ].freeze
+
+      Result = Struct.new(
+        :name, :status, :detail, :fix, :bootstrappable, :path, :version,
+        keyword_init: true
+      ) do
+        def initialize(**kwargs)
+          super
+          self.status = status.to_s
+          self.bootstrappable = !!bootstrappable
+          unless STATUSES.include?(status)
+            raise ArgumentError, "invalid diagnostic status #{status.inspect}"
+          end
+        end
+
+        def ok?
+          status == "ok"
+        end
+
+        def hard_failure?
+          # Bootstrappable rows are recoverable by later setup phases.
+          !ok? && !bootstrappable && status != "unsupported"
+        end
+
+        def to_h
+          {
+            "name" => name,
+            "status" => status,
+            "detail" => detail,
+            "fix" => fix,
+            "bootstrappable" => bootstrappable,
+            "path" => path,
+            "version" => version
+          }
+        end
+      end
+
+      Report = Struct.new(:results, :platform, :ok, keyword_init: true) do
+        def to_h
+          {
+            "schema" => "hive-setup-diagnostics",
+            "schema_version" => 1,
+            "ok" => ok,
+            "platform" => platform,
+            "results" => results.map(&:to_h)
+          }
+        end
+
+        def hard_failures
+          results.select(&:hard_failure?)
+        end
+
+        def bootstrappable
+          results.select(&:bootstrappable)
+        end
+      end
+
+      def initialize(
+        env: ENV,
+        host_os: RbConfig::CONFIG["host_os"],
+        home: nil,
+        which: nil,
+        runner: nil,
+        app_bundle: nil,
+        agent_auth: nil,
+        gh_auth: nil,
+        probe_timeout: PROBE_TIMEOUT_SEC
+      )
+        @env = env
+        @host_os = host_os
+        @home = File.expand_path(home || env["HOME"] || Dir.home)
+        @which = which || method(:default_which)
+        @runner = runner || method(:default_runner)
+        @app_bundle = app_bundle
+        @agent_auth = agent_auth || method(:default_agent_logged_in?)
+        @gh_auth = gh_auth || method(:default_gh_logged_in?)
+        @probe_timeout = probe_timeout
+      end
+
+      def call
+        results = [
+          check_ruby,
+          check_binary("git", fix_missing: install_hint("git")),
+          check_tmux,
+          check_gh,
+          check_agent("claude", fix_login: "claude setup-token"),
+          check_agent("codex", fix_login: "codex login"),
+          check_binary("node", fix_missing: install_hint("node")),
+          check_binary("npm", fix_missing: install_hint("npm")),
+          check_qmd,
+          check_web_bundle,
+          check_sqlite
+        ]
+        Report.new(
+          results: results,
+          platform: platform_name,
+          ok: results.none?(&:hard_failure?)
+        )
+      end
+
+      private
+
+      def check_ruby
+        path = RbConfig.ruby
+        version = RUBY_VERSION
+        if (version_tuple(version) <=> version_tuple(MIN_RUBY)).negative?
+          return result(
+            "ruby",
+            status: "outdated",
+            detail: "ruby #{version} is below minimum #{MIN_RUBY}",
+            fix: install_hint("ruby"),
+            path: path,
+            version: version
+          )
+        end
+
+        result("ruby", status: "ok", detail: "ruby #{version}", path: path, version: version)
+      end
+
+      def check_tmux
+        path = @which.call("tmux")
+        unless path
+          return result(
+            "tmux",
+            status: "missing",
+            detail: "tmux not found on PATH",
+            fix: install_hint("tmux")
+          )
+        end
+
+        version = probe_version(path, %w[--version], /tmux\s+(\d+\.\d+(?:\.\d+)?)/i)
+        if version.nil?
+          return result(
+            "tmux",
+            status: "error",
+            detail: "could not parse tmux --version",
+            fix: install_hint("tmux"),
+            path: path
+          )
+        end
+
+        if (version_tuple(version) <=> version_tuple(MIN_TMUX)).negative?
+          return result(
+            "tmux",
+            status: "outdated",
+            detail: "tmux #{version} is below minimum #{MIN_TMUX}",
+            fix: install_hint("tmux"),
+            path: path,
+            version: version
+          )
+        end
+
+        result("tmux", status: "ok", detail: "tmux #{version}", path: path, version: version)
+      end
+
+      def check_gh
+        path = @which.call("gh")
+        unless path
+          return result(
+            "gh",
+            status: "missing",
+            detail: "gh not found on PATH",
+            fix: install_hint("gh")
+          )
+        end
+
+        version = probe_version(path, %w[--version], /gh version (\d+\.\d+\.\d+)/i)
+        unless @gh_auth.call
+          return result(
+            "gh",
+            status: "unauthenticated",
+            detail: "gh is installed but not authenticated",
+            fix: "gh auth login",
+            path: path,
+            version: version
+          )
+        end
+
+        result(
+          "gh",
+          status: "ok",
+          detail: version ? "gh #{version} authenticated" : "gh authenticated",
+          path: path,
+          version: version
+        )
+      end
+
+      def check_agent(name, fix_login:)
+        path = @which.call(name)
+        unless path
+          return result(
+            name,
+            status: "missing",
+            detail: "#{name} not found on PATH",
+            fix: install_hint(name)
+          )
+        end
+
+        version = probe_version(path, %w[--version], /(\d+\.\d+\.\d+)/)
+        unless @agent_auth.call(name)
+          return result(
+            name,
+            status: "unauthenticated",
+            detail: "#{name} is installed but not authenticated",
+            fix: fix_login,
+            path: path,
+            version: version
+          )
+        end
+
+        result(
+          name,
+          status: "ok",
+          detail: version ? "#{name} #{version} authenticated" : "#{name} authenticated",
+          path: path,
+          version: version
+        )
+      end
+
+      def check_binary(name, fix_missing:)
+        path = @which.call(name)
+        unless path
+          return result(name, status: "missing", detail: "#{name} not found on PATH", fix: fix_missing)
+        end
+
+        version = probe_version(path, %w[--version], /(\d+\.\d+(?:\.\d+)?)/)
+        result(
+          name,
+          status: "ok",
+          detail: version ? "#{name} #{version}" : "#{name} found",
+          path: path,
+          version: version
+        )
+      end
+
+      def check_qmd
+        path = find_qmd
+        if path
+          version = probe_version(path, %w[--version], /(\d+\.\d+\.\d+)/)
+          return result(
+            "qmd",
+            status: "ok",
+            detail: version ? "qmd #{version}" : "qmd found",
+            path: path,
+            version: version
+          )
+        end
+
+        unless @which.call("npm")
+          return result(
+            "qmd",
+            status: "missing",
+            detail: "qmd not found and npm is unavailable to bootstrap it",
+            fix: "#{install_hint('npm')}; then #{qmd_install_command}"
+          )
+        end
+
+        result(
+          "qmd",
+          status: "bootstrappable",
+          detail: "qmd not found; hive can install it under the managed prefix",
+          fix: qmd_install_command,
+          bootstrappable: true
+        )
+      end
+
+      def check_web_bundle
+        bundle = app_bundle
+        begin
+          path = bundle.resolve!(install: false)
+          detail =
+            if bundle.current_managed_bundle?
+              "managed web bundle #{Hive::VERSION}"
+            elsif @env[Hive::Web::AppBundle::ENV_APP_DIR].to_s == path ||
+                  File.expand_path(@env[Hive::Web::AppBundle::ENV_APP_DIR].to_s) == path
+              "Rails app via #{Hive::Web::AppBundle::ENV_APP_DIR}"
+            else
+              "Rails app available"
+            end
+          return result(
+            "web_bundle",
+            status: "ok",
+            detail: detail,
+            path: path,
+            version: bundle.current_managed_bundle? ? Hive::VERSION : bundle.installed_version
+          )
+        rescue Hive::Web::AppBundle::Error
+          # fall through to bootstrappable
+        end
+
+        if bundle.rails_app?(bundle.managed_app_dir)
+          installed = bundle.installed_version
+          return result(
+            "web_bundle",
+            status: "bootstrappable",
+            detail: "managed web bundle is stale (#{installed.inspect}); needs #{Hive::VERSION}",
+            fix: "hive setup  # refreshes web bundle to #{Hive::VERSION}",
+            path: bundle.managed_app_dir,
+            version: installed,
+            bootstrappable: true
+          )
+        end
+
+        result(
+          "web_bundle",
+          status: "bootstrappable",
+          detail: "Rails web bundle not installed for hive #{Hive::VERSION}",
+          fix: "hive setup  # downloads #{bundle.archive_basename}",
+          bootstrappable: true
+        )
+      end
+
+      def check_sqlite
+        # Prefer the sqlite3 gem (already a hive runtime dep) over a CLI.
+        begin
+          require "sqlite3"
+          version = defined?(SQLite3::SQLITE_VERSION) ? SQLite3::SQLITE_VERSION : SQLite3::VERSION
+          return result(
+            "sqlite",
+            status: "ok",
+            detail: "sqlite3 gem #{version}",
+            version: version.to_s
+          )
+        rescue LoadError
+          # fall through to CLI probe
+        end
+
+        path = @which.call("sqlite3")
+        unless path
+          return result(
+            "sqlite",
+            status: "missing",
+            detail: "sqlite3 gem and sqlite3 CLI are unavailable",
+            fix: install_hint("sqlite3")
+          )
+        end
+
+        version = probe_version(path, %w[--version], /(\d+\.\d+\.\d+)/)
+        result(
+          "sqlite",
+          status: "ok",
+          detail: version ? "sqlite3 #{version}" : "sqlite3 CLI found",
+          path: path,
+          version: version
+        )
+      end
+
+      def result(name, status:, detail:, fix: nil, bootstrappable: false, path: nil, version: nil)
+        Result.new(
+          name: name,
+          status: status,
+          detail: detail,
+          fix: fix,
+          bootstrappable: bootstrappable,
+          path: path,
+          version: version
+        )
+      end
+
+      def app_bundle
+        @app_bundle ||= Hive::Web::AppBundle.new(
+          env: @env,
+          data_home: Hive::Paths.data_home,
+          # Production diagnostics use the real source-checkout fallback.
+          # Tests inject an AppBundle with source_app_dir pointed at a
+          # non-existent path so the suite stays hermetic.
+          downloader: ->(*) { raise "diagnostics must not download" },
+          bundler: ->(*) { raise "diagnostics must not bundle install" }
+        )
+      end
+
+      def find_qmd
+        env_qmd = @env["HIVE_QMD_BIN"].to_s
+        return env_qmd if !env_qmd.empty? && File.executable?(env_qmd)
+
+        path_qmd = @which.call("qmd")
+        return path_qmd if path_qmd
+
+        data_home = Hive::Paths.data_home
+        candidates = [
+          File.join(data_home, "qmd", "bin", "qmd"),
+          File.expand_path("~/.local/share/hive/qmd/bin/qmd", @home)
+        ]
+        # Paths.expand with ~ uses HOME; keep explicit home-relative too.
+        candidates << File.join(@home, ".local/share/hive/qmd/bin/qmd")
+
+        prefix_file = File.join(data_home, "install-prefix")
+        if File.readable?(prefix_file)
+          prefix = File.read(prefix_file).lines.first.to_s.strip
+          candidates << File.join(prefix, "hive", "qmd", "bin", "qmd") unless prefix.empty?
+        end
+
+        candidates.find { |candidate| File.file?(candidate) && File.executable?(candidate) }
+      end
+
+      def qmd_install_command
+        prefix = File.join(Hive::Paths.data_home, "qmd")
+        "npm install --global --prefix \"#{prefix}\" #{QMD_PACKAGE}"
+      end
+
+      def install_hint(name)
+        case platform_name
+        when "macos"
+          case name
+          when "ruby" then "brew install ruby@3.4  # or install Ruby 3.4+ via mise/rbenv/asdf"
+          when "git" then "brew install git"
+          when "tmux" then "brew install tmux"
+          when "gh" then "brew install gh"
+          when "node", "npm" then "brew install node"
+          when "sqlite3" then "brew install sqlite"
+          when "claude" then "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code"
+          when "codex" then "Install Codex CLI: https://github.com/openai/codex"
+          else "brew install #{name}"
+          end
+        when "linux"
+          case name
+          when "ruby" then "Install Ruby >= #{MIN_RUBY} via your package manager or mise/rbenv/asdf"
+          when "git" then "sudo apt-get install -y git  # or equivalent for your distro"
+          when "tmux" then "sudo apt-get install -y tmux  # or equivalent for your distro"
+          when "gh" then "See https://github.com/cli/cli#installation"
+          when "node", "npm" then "sudo apt-get install -y nodejs npm  # or equivalent for your distro"
+          when "sqlite3" then "sudo apt-get install -y sqlite3 libsqlite3-dev  # or equivalent"
+          when "claude" then "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code"
+          when "codex" then "Install Codex CLI: https://github.com/openai/codex"
+          else "Install #{name} via your package manager"
+          end
+        else
+          "Install #{name} for your platform (Linux/macOS supported for local setup)"
+        end
+      end
+
+      def platform_name
+        case @host_os
+        when /darwin/i then "macos"
+        when /linux/i then "linux"
+        else "unsupported"
+        end
+      end
+
+      def probe_version(path, argv, pattern)
+        out, err, status = @runner.call([ path, *argv ])
+        return nil unless status
+
+        text = "#{out}\n#{err}"
+        match = text.match(pattern)
+        match && match[1]
+      rescue StandardError
+        nil
+      end
+
+      def default_which(name)
+        @env["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
+          path = File.join(dir, name)
+          return path if File.file?(path) && File.executable?(path)
+        end
+        nil
+      end
+
+      def default_runner(argv)
+        out, err, status = Hive::BoundedSubprocess.capture3(*argv, timeout: @probe_timeout)
+        [ out, err, status.success? ]
+      rescue Hive::BoundedSubprocess::TimeoutError, Errno::ENOENT, Errno::EACCES, SystemCallError
+        [ "", "", false ]
+      end
+
+      def default_agent_logged_in?(name)
+        return true if api_key_present?(name)
+
+        Hive::AgentProfiles.logged_in?(name, home: @home)
+      end
+
+      def api_key_present?(name)
+        case name.to_s
+        when "claude"
+          key_set?("ANTHROPIC_API_KEY") || key_set?("CLAUDE_API_KEY")
+        when "codex"
+          key_set?("OPENAI_API_KEY")
+        else
+          false
+        end
+      end
+
+      def key_set?(name)
+        value = @env[name].to_s.strip
+        !value.empty?
+      end
+
+      def default_gh_logged_in?
+        out, err, ok = default_runner(%w[gh auth status])
+        return true if ok
+
+        # Some gh versions write status details to stderr even on success.
+        text = "#{out}\n#{err}"
+        return true if text.match?(/Logged in to/i)
+
+        false
+      rescue StandardError
+        false
+      end
+
+      def version_tuple(version)
+        version.to_s.split(".").map(&:to_i)
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/app_bundle.rb b/lib/hive/web/app_bundle.rb
new file mode 100644
index 00000000..5a2176d3
--- /dev/null
+++ b/lib/hive/web/app_bundle.rb
@@ -0,0 +1,584 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "digest"
+require "net/http"
+require "openssl"
+require "rubygems/package"
+require "securerandom"
+require "timeout"
+require "tmpdir"
+require "uri"
+require "zlib"
+
+require "hive"
+require "hive/bounded_subprocess"
+require "hive/paths"
+
+module Hive
+  module Web
+    # Resolves and installs the version-matched Rails application bundle used
+    # by local (non-Docker) `hive web`. The gem intentionally excludes `web/`;
+    # gem/Homebrew/AUR installs acquire the matching GitHub release asset
+    # (`hive-web-<version>.tar.gz`) into `Hive::Paths.web_app_home`.
+    #
+    # Resolution order for `#resolve!`:
+    #   1. `HIVEBOX_WEB_APP_DIR` (explicit override; Docker and tests)
+    #   2. Current managed bundle (stamped with `Hive::VERSION`)
+    #   3. Source checkout `web/` next to the gem lib tree
+    #   4. Download + install the matching release asset
+    #
+    # Install is staged, Bundler-verified, then atomically swapped. A failed
+    # refresh leaves any previous working bundle intact; a failed first
+    # install leaves no half-provisioned target.
+    class AppBundle
+      ENV_APP_DIR = "HIVEBOX_WEB_APP_DIR"
+      VERSION_STAMP_NAME = ".hive-web-version"
+      RAILS_MARKER = File.join("config", "application.rb")
+      ARCHIVE_BASENAME_PREFIX = "hive-web-"
+      DEFAULT_CONNECT_TIMEOUT = 10
+      DEFAULT_READ_TIMEOUT = 120
+      DEFAULT_BUNDLE_TIMEOUT = 600
+      DEFAULT_CHECKSUM_VERIFY_TIMEOUT = 60
+      MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
+      MAX_CHECKSUM_ASSET_BYTES = 1024 * 1024
+      MAX_ARCHIVE_ENTRIES = 20_000
+      MAX_ARCHIVE_ENTRY_BYTES = 64 * 1024 * 1024
+      MAX_ARCHIVE_EXPANDED_BYTES = 256 * 1024 * 1024
+
+      class Error < Hive::Error; end
+
+      attr_reader :messages
+
+      def initialize(
+        version: Hive::VERSION,
+        data_home: nil,
+        source_app_dir: nil,
+        env: ENV,
+        downloader: nil,
+        bundler: nil,
+        tar_reader: nil,
+        http_factory: nil,
+        subprocess_runner: nil,
+        connect_timeout: DEFAULT_CONNECT_TIMEOUT,
+        read_timeout: DEFAULT_READ_TIMEOUT,
+        bundle_timeout: DEFAULT_BUNDLE_TIMEOUT,
+        max_archive_bytes: MAX_ARCHIVE_BYTES,
+        max_archive_entries: MAX_ARCHIVE_ENTRIES,
+        max_archive_entry_bytes: MAX_ARCHIVE_ENTRY_BYTES,
+        max_archive_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES
+      )
+        @version = version.to_s
+        @data_home = data_home
+        @source_app_dir = source_app_dir
+        @env = env
+        @downloader = downloader
+        @bundler = bundler || method(:default_bundle_install)
+        @tar_reader = tar_reader
+        @http_factory = http_factory
+        @subprocess_runner = subprocess_runner || Hive::BoundedSubprocess.method(:capture3)
+        @connect_timeout = connect_timeout
+        @read_timeout = read_timeout
+        @bundle_timeout = bundle_timeout
+        @max_archive_bytes = max_archive_bytes
+        @max_archive_entries = max_archive_entries
+        @max_archive_entry_bytes = max_archive_entry_bytes
+        @max_archive_expanded_bytes = max_archive_expanded_bytes
+        @messages = []
+      end
+
+      # Return a usable Rails app directory, installing the managed bundle
+      # when needed. Raises `AppBundle::Error` when no path can be resolved.
+      def resolve!(install: true)
+        if (explicit = explicit_app_dir)
+          return explicit
+        end
+
+        if current_managed_bundle?
+          return managed_app_dir
+        end
+
+        if (source = source_checkout_app_dir)
+          return source
+        end
+
+        raise Error, missing_bundle_message unless install
+
+        ensure_installed!
+        managed_app_dir
+      end
+
+      # True when a stamped managed bundle matching `@version` is present.
+      def current_managed_bundle?
+        rails_app?(managed_app_dir) && installed_version == @version
+      end
+
+      def installed_version
+        path = version_stamp_path
+        return nil unless File.file?(path)
+
+        File.read(path).strip
+      rescue SystemCallError
+        nil
+      end
+
+      def managed_app_dir
+        @data_home ? File.join(@data_home, "web-app") : Hive::Paths.web_app_home
+      end
+
+      def version_stamp_path
+        File.join(managed_app_dir, VERSION_STAMP_NAME)
+      end
+
+      def archive_basename(version = @version)
+        "#{ARCHIVE_BASENAME_PREFIX}#{version}.tar.gz"
+      end
+
+      def release_asset_url(version = @version)
+        "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/releases/download/v#{version}/#{archive_basename(version)}"
+      end
+
+      # Bundler installs managed web gems below the app tree rather than into
+      # the invoking CLI's GEM_HOME. Source checkouts and explicit Docker/test
+      # overrides keep their normal development environment.
+      def runtime_env(app_dir)
+        return {} unless File.expand_path(app_dir.to_s) == File.expand_path(managed_app_dir)
+
+        bundle_env(managed_app_dir)
+      end
+
+      # Install or refresh the managed bundle for `@version`. Idempotent when
+      # the current stamp already matches.
+      def ensure_installed!(source: nil)
+        return managed_app_dir if current_managed_bundle? && source.nil?
+
+        if source
+          install_from_directory!(source)
+        else
+          install_from_release!
+        end
+        managed_app_dir
+      end
+
+      # Install from an already-extracted Rails tree (tests / offline fixtures).
+      def install_from_directory!(source_dir)
+        source_dir = File.expand_path(source_dir.to_s)
+        unless rails_app?(source_dir)
+          raise Error, "web app source at #{source_dir} is missing #{RAILS_MARKER}"
+        end
+
+        stage_and_swap! do |staging|
+          copy_tree(source_dir, staging)
+        end
+      end
+
+      # Download the matching release archive and install it.
+      def install_from_release!(url: release_asset_url)
+        Dir.mktmpdir("hive-web-dl-") do |tmpdir|
+          archive_path = File.join(tmpdir, archive_basename)
+          checksums_path = File.join(tmpdir, "SHA256SUMS")
+          download_asset(checksums_url(url), checksums_path, max_bytes: MAX_CHECKSUM_ASSET_BYTES)
+          verify_checksums_signature!(url, checksums_path, tmpdir)
+          download_asset(url, archive_path, max_bytes: @max_archive_bytes)
+          verify_archive_checksum!(archive_path, checksums_path)
+          extract_and_install!(archive_path)
+        end
+      end
+
+      # Extract a local `.tar.gz` and install it into the managed path.
+      def extract_and_install!(archive_path)
+        if File.size(archive_path) > @max_archive_bytes
+          raise Error, "#{File.basename(archive_path)} archive is too large " \
+            "(max #{@max_archive_bytes} bytes)"
+        end
+
+        Dir.mktmpdir("hive-web-extract-") do |extract_root|
+          extract_archive!(archive_path, extract_root)
+          rails_root = find_rails_root(extract_root)
+          unless rails_root
+            raise Error, "archive does not contain a Rails app (missing #{RAILS_MARKER})"
+          end
+
+          stage_and_swap! do |staging|
+            copy_tree(rails_root, staging)
+          end
+        end
+      end
+
+      def rails_app?(dir)
+        !dir.to_s.empty? && File.file?(File.join(dir, RAILS_MARKER))
+      end
+
+      private
+
+      def explicit_app_dir
+        raw = @env[ENV_APP_DIR].to_s
+        return nil if raw.empty?
+
+        dir = File.expand_path(raw)
+        return dir if rails_app?(dir)
+
+        @messages << "HIVEBOX_WEB_APP_DIR=#{dir} does not contain #{RAILS_MARKER}"
+        nil
+      end
+
+      def source_checkout_app_dir
+        candidates = []
+        candidates << @source_app_dir if @source_app_dir
+        # lib/hive/web/app_bundle.rb → repo_root/web
+        candidates << File.expand_path("../../../web", __dir__)
+        candidates.find { |dir| rails_app?(dir) }
+      end
+
+      def stage_and_swap!
+        target = managed_app_dir
+        parent = File.dirname(target)
+        FileUtils.mkdir_p(parent)
+
+        staging = "#{target}.staging.#{Process.pid}.#{SecureRandom.hex(4)}"
+        backup = nil
+        begin
+          FileUtils.rm_rf(staging)
+          FileUtils.mkdir_p(staging)
+          yield staging
+
+          unless rails_app?(staging)
+            raise Error, "staged web app is missing #{RAILS_MARKER}"
+          end
+
+          @bundler.call(staging)
+
+          # Stamp last so a partial install is never considered current.
+          File.write(File.join(staging, VERSION_STAMP_NAME), "#{@version}\n")
+
+          if File.exist?(target)
+            backup = "#{target}.prev.#{Process.pid}.#{SecureRandom.hex(4)}"
+            File.rename(target, backup)
+          end
+          File.rename(staging, target)
+          FileUtils.rm_rf(backup) if backup
+          @messages << "installed web app bundle #{@version} at #{target}"
+          target
+        rescue StandardError
+          FileUtils.rm_rf(staging) if staging && File.exist?(staging)
+          if backup && File.exist?(backup) && !File.exist?(target)
+            File.rename(backup, target)
+          end
+          raise
+        ensure
+          FileUtils.rm_rf(staging) if staging && File.exist?(staging)
+          FileUtils.rm_rf(backup) if backup && File.exist?(backup) && File.exist?(target)
+        end
+      end
+
+      def copy_tree(source, dest)
+        FileUtils.mkdir_p(dest)
+        Dir.children(source).each do |entry|
+          next if entry == VERSION_STAMP_NAME || entry == "." || entry == ".."
+
+          src = File.join(source, entry)
+          FileUtils.cp_r(src, File.join(dest, entry), preserve: true)
+        end
+      end
+
+      def extract_archive!(archive_path, dest_root)
+        dest_root = File.expand_path(dest_root)
+        FileUtils.mkdir_p(dest_root)
+        entry_count = 0
+        expanded_bytes = 0
+
+        open_tar(archive_path) do |tar|
+          tar.each do |entry|
+            entry_count += 1
+            if entry_count > @max_archive_entries
+              raise Error, "archive contains too many entries (max #{@max_archive_entries})"
+            end
+            validate_tar_entry!(entry, dest_root)
+            dest = File.join(dest_root, entry.full_name)
+
+            if entry.directory?
+              FileUtils.mkdir_p(dest)
+            elsif entry.file?
+              entry_size = Integer(entry.header.size)
+              if entry_size > @max_archive_entry_bytes
+                raise Error, "archive entry is too large: #{entry.full_name.inspect} " \
+                  "(max #{@max_archive_entry_bytes} bytes)"
+              end
+              expanded_bytes += entry_size
+              if expanded_bytes > @max_archive_expanded_bytes
+                raise Error, "archive expanded contents are too large " \
+                  "(max #{@max_archive_expanded_bytes} bytes)"
+              end
+              FileUtils.mkdir_p(File.dirname(dest))
+              File.open(dest, "wb", entry.header.mode & 0o755) do |out|
+                while (chunk = entry.read(1024 * 64))
+                  out.write(chunk)
+                end
+              end
+              # Strip setuid/setgid/sticky; keep owner rwx only as recorded.
+              File.chmod(entry.header.mode & 0o755, dest)
+            else
+              raise Error, "archive contains unsupported entry type for #{entry.full_name.inspect}"
+            end
+          end
+        end
+      end
+
+      def open_tar(archive_path, &block)
+        if @tar_reader
+          @tar_reader.call(archive_path, &block)
+          return
+        end
+
+        Zlib::GzipReader.open(archive_path) do |gz|
+          Gem::Package::TarReader.new(gz, &block)
+        end
+      rescue Zlib::Error, Gem::Package::TarInvalidError => e
+        raise Error, "invalid web app archive: #{e.message}"
+      end
+
+      def validate_tar_entry!(entry, dest_root)
+        name = entry.full_name.to_s
+        if name.empty?
+          raise Error, "archive contains an entry with an empty path"
+        end
+        if name.start_with?("/") || name.match?(%r{\A[A-Za-z]:[\\/]})
+          raise Error, "archive entry has absolute path: #{name.inspect}"
+        end
+        if name.split("/").include?("..")
+          raise Error, "archive entry escapes staging root: #{name.inspect}"
+        end
+        if entry.symlink? || entry.header.typeflag == "2" || entry.header.typeflag == "1"
+          raise Error, "archive contains link entry (rejected): #{name.inspect}"
+        end
+
+        dest = File.expand_path(File.join(dest_root, name))
+        unless dest == dest_root || dest.start_with?("#{dest_root}#{File::SEPARATOR}")
+          raise Error, "archive entry escapes staging root: #{name.inspect}"
+        end
+      end
+
+      def find_rails_root(extract_root)
+        return extract_root if rails_app?(extract_root)
+
+        # Single versioned wrapper directory (e.g. hive-web-0.3.2/...) is OK.
+        children = Dir.children(extract_root).reject { |n| n.start_with?(".") }
+        return nil unless children.length == 1
+
+        candidate = File.join(extract_root, children.first)
+        return candidate if File.directory?(candidate) && rails_app?(candidate)
+
+        nil
+      end
+
+      def checksums_url(asset_url)
+        sibling_asset_url(asset_url, "SHA256SUMS")
+      end
+
+      def sibling_asset_url(asset_url, basename)
+        uri = URI.parse(asset_url)
+        uri.path = File.join(File.dirname(uri.path), basename)
+        uri.query = nil
+        uri.fragment = nil
+        uri.to_s
+      end
+
+      def download_asset(url, dest_path, max_bytes:)
+        if @downloader
+          @downloader.call(url, dest_path)
+        else
+          default_download(url, dest_path, max_bytes: max_bytes)
+        end
+        unless File.file?(dest_path)
+          raise Error, "download did not create #{File.basename(dest_path)}"
+        end
+        if File.size(dest_path) > max_bytes
+          FileUtils.rm_f(dest_path)
+          raise Error, "#{File.basename(dest_path)} download is too large (max #{max_bytes} bytes)"
+        end
+        dest_path
+      end
+
+      def default_download(url, dest_path, max_bytes: @max_archive_bytes)
+        uri = URI.parse(url)
+        unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
+          raise Error, "web app download URL must be http(s); got #{url.inspect}"
+        end
+
+        part_path = "#{dest_path}.part.#{Process.pid}.#{SecureRandom.hex(4)}"
+        fetch_with_redirects(uri) do |response|
+          unless response.is_a?(Net::HTTPSuccess)
+            code = response.respond_to?(:code) ? response.code : "?"
+            raise Error, "failed to download web app bundle from #{url} (HTTP #{code})"
+          end
+
+          length = Integer(response["content-length"], exception: false)
+          if length && length > max_bytes
+            raise Error, "#{File.basename(dest_path)} download is too large (max #{max_bytes} bytes)"
+          end
+
+          downloaded = 0
+          File.open(part_path, "wb", 0o600) do |file|
+            response.read_body do |chunk|
+              downloaded += chunk.bytesize
+              if downloaded > max_bytes
+                raise Error, "#{File.basename(dest_path)} download is too large (max #{max_bytes} bytes)"
+              end
+              file.write(chunk)
+            end
+          end
+        end
+        File.rename(part_path, dest_path)
+        dest_path
+      rescue SocketError, Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
+             Errno::ENETUNREACH, OpenSSL::SSL::SSLError, Net::OpenTimeout,
+             Net::ReadTimeout => e
+        raise Error, "failed to download web app bundle from #{url}: #{e.class}: #{e.message}"
+      ensure
+        FileUtils.rm_f(part_path) if defined?(part_path) && part_path
+      end
+
+      def fetch_with_redirects(uri, limit: 5, &block)
+        raise Error, "too many redirects downloading web app bundle" if limit <= 0
+
+        http = @http_factory ? @http_factory.call(uri) : Net::HTTP.new(uri.host, uri.port)
+        http.use_ssl = uri.scheme == "https"
+        http.open_timeout = @connect_timeout
+        http.read_timeout = @read_timeout
+        request = Net::HTTP::Get.new(uri.request_uri)
+        request["User-Agent"] = "hive-cli/#{@version}"
+        http.request(request) do |response|
+          case response
+          when Net::HTTPRedirection
+            location = response["location"].to_s
+            raise Error, "redirect without Location header" if location.empty?
+
+            redirected = URI.join(uri.to_s, location)
+            unless redirected.is_a?(URI::HTTP) || redirected.is_a?(URI::HTTPS)
+              raise Error, "web app redirect URL must be http(s); got #{redirected}"
+            end
+            if uri.scheme == "https" && redirected.scheme == "http"
+              raise Error, "refusing HTTPS to HTTP redirect downloading web app bundle"
+            end
+            return fetch_with_redirects(redirected, limit: limit - 1, &block)
+          else
+            return block.call(response)
+          end
+        end
+      end
+
+      def verify_archive_checksum!(archive_path, checksums_path)
+        basename = File.basename(archive_path)
+        pattern = /\A([a-f0-9]{64})  (?:\.\/)?#{Regexp.escape(basename)}\s*\z/
+        matches = File.foreach(checksums_path).filter_map { |line| line.match(pattern)&.[](1) }.uniq
+        if matches.empty?
+          raise Error, "SHA256SUMS does not contain #{basename}"
+        end
+        if matches.length > 1
+          raise Error, "SHA256SUMS contains conflicting entries for #{basename}"
+        end
+
+        actual = ::Digest::SHA256.file(archive_path).hexdigest
+        return if actual == matches.first
+
+        raise Error, "checksum mismatch for #{basename}"
+      rescue SystemCallError => e
+        raise Error, "could not verify #{basename}: #{e.message}"
+      end
+
+      def verify_checksums_signature!(asset_url, checksums_path, tmpdir)
+        cosign = find_executable("cosign")
+        return unless cosign
+
+        signature_path = File.join(tmpdir, "SHA256SUMS.sig")
+        certificate_path = File.join(tmpdir, "SHA256SUMS.pem")
+        download_asset(
+          sibling_asset_url(asset_url, "SHA256SUMS.sig"), signature_path,
+          max_bytes: MAX_CHECKSUM_ASSET_BYTES
+        )
+        download_asset(
+          sibling_asset_url(asset_url, "SHA256SUMS.pem"), certificate_path,
+          max_bytes: MAX_CHECKSUM_ASSET_BYTES
+        )
+        identity = "^https://github\\.com/#{Regexp.escape(Hive::REPO_OWNER)}/#{Regexp.escape(Hive::REPO_NAME)}/"
+        _out, err, status = @subprocess_runner.call(
+          cosign, "verify-blob",
+          "--certificate", certificate_path,
+          "--signature", signature_path,
+          "--certificate-identity-regexp", identity,
+          "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
+          checksums_path,
+          timeout: DEFAULT_CHECKSUM_VERIFY_TIMEOUT
+        )
+        return if status.success?
+
+        raise Error, "cosign verification failed for SHA256SUMS: #{err.to_s.strip}"
+      rescue Hive::BoundedSubprocess::TimeoutError
+        raise Error, "cosign verification timed out after #{DEFAULT_CHECKSUM_VERIFY_TIMEOUT}s"
+      end
+
+      def find_executable(name)
+        @env["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
+          candidate = File.join(dir, name)
+          return candidate if File.file?(candidate) && File.executable?(candidate)
+        end
+        nil
+      end
+
+      def default_bundle_install(app_dir)
+        gemfile = File.join(app_dir, "Gemfile")
+        return unless File.file?(gemfile)
+
+        env = bundle_env(app_dir)
+        # Prefer frozen install when Gemfile.lock is present; fall back to a
+        # network-enabled install when the release's vendor/cache does not
+        # contain every platform-specific Rails dependency.
+        cmd =
+          if File.file?(File.join(app_dir, "Gemfile.lock"))
+            %w[bundle install --local]
+          else
+            %w[bundle install]
+          end
+
+        out, err, status = run_bundle_install(env, cmd, app_dir)
+        return if status.success?
+
+        # --local can fail when vendor/cache does not hold every dependency;
+        # retry without it once while preserving the same hard deadline.
+        if cmd.include?("--local")
+          out, err, status = run_bundle_install(env, %w[bundle install], app_dir)
+          return if status.success?
+        end
+
+        detail = [ out, err ].map(&:to_s).map(&:strip).reject(&:empty?).join("\n")
+        raise Error, "bundle install failed for web app at #{app_dir}: #{detail}"
+      end
+
+      def bundle_env(app_dir)
+        {
+          # Point Bundler's app-local config lookup at the managed tree. A
+          # caller-level BUNDLE_APP_CONFIG can otherwise load a path setting
+          # that outranks BUNDLE_PATH and sends gems back into the CLI bundle.
+          "BUNDLE_APP_CONFIG" => File.join(app_dir, ".bundle"),
+          "BUNDLE_GEMFILE" => File.join(app_dir, "Gemfile"),
+          "BUNDLE_PATH" => File.join(app_dir, "vendor", "bundle"),
+          "BUNDLE_DEPLOYMENT" => "1",
+          "BUNDLE_WITHOUT" => "development:test",
+          "RAILS_ENV" => "production"
+        }
+      end
+
+      def run_bundle_install(env, cmd, app_dir)
+        @subprocess_runner.call(env, *cmd, chdir: app_dir, timeout: @bundle_timeout)
+      rescue Hive::BoundedSubprocess::TimeoutError
+        raise Error, "bundle install timed out after #{@bundle_timeout}s for web app at #{app_dir}"
+      end
+
+      def missing_bundle_message
+        "hive web: no Rails app found. Install the matching release asset " \
+          "(#{archive_basename}) under #{managed_app_dir}, run from a source " \
+          "checkout, or set #{ENV_APP_DIR} to a Rails app directory."
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/dispatcher.rb b/lib/hive/web/dispatcher.rb
index 10683729..93c77f4f 100644
--- a/lib/hive/web/dispatcher.rb
+++ b/lib/hive/web/dispatcher.rb
@@ -11,6 +11,29 @@ require "hive/stages"
 module Hive
   module Web
     class Dispatcher
+      # Enqueue the only allowlisted host-maintenance command so a running
+      # daemon rewrites its unit with the current CLI binary. Rails workers
+      # never shell out to rewrite services themselves.
+      def queue_daemon_repair!
+        require "hive/daemon/dispatch_request_queue"
+        argv = Hive::Daemon::DispatchRequestQueue::MAINTENANCE_ARGV.dup
+        unless Hive::Daemon::DispatchRequestQueue.valid_argv?(
+          argv,
+          project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT,
+          slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG
+        )
+          raise Hive::Error, "daemon repair argv is not allowlisted"
+        end
+
+        Hive::Daemon::DispatchRequestQueue.write_request!(
+          project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT,
+          slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG,
+          argv: argv,
+          requestor: "bot",
+          trigger: "daemon_repair"
+        )
+      end
+
       STAGE_VERB_BY_ACTION = {
         "ready_to_brainstorm" => "brainstorm",
         "ready_to_plan" => "plan",
diff --git a/lib/hive/web/loopback.rb b/lib/hive/web/loopback.rb
new file mode 100644
index 00000000..0d97717b
--- /dev/null
+++ b/lib/hive/web/loopback.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "ipaddr"
+
+module Hive
+  module Web
+    # Shared loopback classifier used by the CLI bind gate (`hive web`) and
+    # the Rails request auth bypass. One implementation so the two sides
+    # cannot disagree about what counts as loopback.
+    module Loopback
+      module_function
+
+      # Environment flag set by the CLI only when the configured bind is
+      # loopback AND config `web.local_loopback` is enabled. Rails re-checks
+      # the actual peer address before skipping login.
+      ENV_MODE = "HIVE_WEB_LOCAL_LOOPBACK"
+
+      # Hostnames treated as loopback when used as a bind address.
+      LOOPBACK_HOSTS = %w[localhost].freeze
+
+      def address?(value)
+        raw = value.to_s.strip
+        return false if raw.empty?
+        return true if LOOPBACK_HOSTS.include?(raw.downcase)
+
+        # Strip IPv6 brackets: "[::1]"
+        host = raw.sub(/\A\[(.*)\]\z/, '\1')
+        # Strip optional :port for IPv4 ("127.0.0.1:4567") — not for IPv6.
+        if host.count(":") == 1 && host.match?(/\A[\d.]+:\d+\z/)
+          host = host.split(":", 2).first
+        end
+
+        ip = IPAddr.new(host)
+        ip.loopback?
+      rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
+        false
+      end
+
+      def mode_enabled?(env = ENV)
+        %w[1 true yes].include?(env[ENV_MODE].to_s.strip.downcase)
+      end
+
+      def enable_mode!(env = ENV)
+        env[ENV_MODE] = "1"
+      end
+
+      def disable_mode!(env = ENV)
+        env.delete(ENV_MODE)
+      end
+    end
+  end
+end
diff --git a/openclaw/skills/hive/SKILL.md b/openclaw/skills/hive/SKILL.md
index 4eecc409..f8cd6392 100644
--- a/openclaw/skills/hive/SKILL.md
+++ b/openclaw/skills/hive/SKILL.md
@@ -41,11 +41,11 @@ That listing installs the `/hive` slash command. First run should normally be:
 
 ## Common Paths
 
-- `/hive setup` installs or verifies the Hive CLI, enables the per-user daemon service, and optionally initializes the current repository.
+- `/hive setup` installs or verifies the Hive CLI, enables the per-user daemon service, and optionally initializes the current repository. Prefer `hive setup` / `hive setup --service` for full local provision (diagnostics, qmd/web bundle, daemon, enrollment, optional managed web on `127.0.0.1:4567`).
 - `/hive status --json` shows the task board and next actions.
 - `/hive new . "build this feature"` creates a new Hive task in the current project.
 - `/hive plan <task-slug>`, `/hive develop <task-slug>`, and `/hive review <task-slug>` advance a task through the main coding workflow.
-- `/hive web` starts the Hivebox browser surface when a user wants the local web UI.
+- `/hive web` starts the local Rails UI in the foreground; `hive web install|start|stop|status` manage the separate hive-web service. Hivebox Docker remains an alternative install path.
 - `/hive wiki compile-log --check` verifies that `wiki/log.md` matches the fragments in `wiki/log.d/`.
 - `/hive doctor` checks local runtime and skill configuration.
 
@@ -63,7 +63,7 @@ else
 fi
 ```
 
-If `hive_cmd` is empty, start guided setup instead of failing. Restate that setup will install the Hive CLI, verify it, install or enable the per-user daemon service, and optionally run `hive init` for the current project. Get explicit user confirmation before running installers.
+If `hive_cmd` is empty, start guided setup instead of failing. Restate that setup will install the Hive CLI, verify it, run the unified `hive setup` provisioner, install or enable the per-user daemon service, and optionally initialize or enroll the current project. Get explicit user confirmation before running installers.
 
 ## Guided Setup
 
@@ -81,7 +81,15 @@ curl -fsSL https://raw.githubusercontent.com/ivankuznetsov/hive/v0.2.0/install.s
 bash "$tmpdir/hive-install.sh"
 ```
 
-After install, run the strict `hive` / `hv` version check again. If neither command prints a bare `X.Y.Z` version, stop and report that setup failed or Apache Hive may be shadowing the command. If verification succeeds, run `"${hive_cmd}" daemon install` once. Then ask whether to initialize the current project; if yes, run `"${hive_cmd}" init . --json </dev/null`, followed by `"${hive_cmd}" doctor` non-fatally. Summarize the installed version, daemon setup result, project initialization result, and any missing runtime dependencies.
+After install, run the strict `hive` / `hv` version check again. If neither command prints a bare `X.Y.Z` version, stop and report that setup failed or Apache Hive may be shadowing the command.
+
+If verification succeeds, ask whether to install the managed `hive-web` service and whether to initialize/enroll the current repository. Then run the new provisioner once:
+
+```bash
+"${hive_cmd}" setup --json
+```
+
+Add `--service` when the user wants the managed web service on `127.0.0.1:4567`; add `--no-init` when they do not want the current repository initialized or enrolled. Do not reproduce the old `daemon install` plus `init` sequence: `hive setup` owns bounded diagnostics, qmd and version-matched web bundle bootstrap, daemon installation, fresh initialization or existing-project enrollment, and optional web-service installation. Summarize the installed version and all reported setup phases, including diagnostics fixes and enrollment results; the JSON `ok` value and process exit cover the complete report.
 
 ## Dispatch Rules
 
diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json
index 1ee77d0d..ded65037 100644
--- a/schemas/hive-daemon-status.v1.json
+++ b/schemas/hive-daemon-status.v1.json
@@ -23,7 +23,11 @@
         "service_enabled",
         "unit_path",
         "current_version",
-        "update_nudge"
+        "update_nudge",
+        "expected_binary",
+        "installed_binary",
+        "installed_version",
+        "binary_drift"
       ],
       "properties": {
         "schema": { "const": "hive-daemon-status" },
@@ -75,6 +79,23 @@
             "channel": { "type": "string", "description": "Detected install channel (brew/aur/bash)." },
             "command": { "type": "string", "description": "Exact command to update on this channel." }
           }
+        },
+        "expected_binary": {
+          "type": ["string", "null"],
+          "description": "Absolute path of the hive binary the current CLI would write into the unit."
+        },
+        "installed_binary": {
+          "type": ["string", "null"],
+          "description": "Absolute path of the hive binary parsed from the installed unit file, or null when missing/unparseable."
+        },
+        "installed_version": {
+          "type": ["string", "null"],
+          "description": "Version string from `installed_binary --version` (bounded probe), or null when unavailable."
+        },
+        "binary_drift": {
+          "type": "string",
+          "enum": ["none", "path", "version", "unparseable", "unreadable", "not_applicable"],
+          "description": "Closed drift enum comparing installed unit binary/version with the current CLI."
         }
       }
     }
diff --git a/schemas/hive-setup.v1.json b/schemas/hive-setup.v1.json
new file mode 100644
index 00000000..dc76e336
--- /dev/null
+++ b/schemas/hive-setup.v1.json
@@ -0,0 +1,74 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-setup.v1.json",
+  "title": "hive setup output (v1)",
+  "description": "Stable complete phase report emitted by `hive setup --json`, including failed diagnostic and provisioning outcomes before the command exits non-zero.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "schema", "schema_version", "ok", "service", "no_bootstrap",
+    "no_init", "binary_path", "project_path", "web_url", "diagnostics", "phases"
+  ],
+  "properties": {
+    "schema": { "const": "hive-setup" },
+    "schema_version": { "const": 1 },
+    "ok": { "type": "boolean" },
+    "service": { "type": "boolean" },
+    "no_bootstrap": { "type": "boolean" },
+    "no_init": { "type": "boolean" },
+    "binary_path": { "type": ["string", "null"] },
+    "project_path": { "type": "string" },
+    "web_url": { "type": "string", "const": "http://127.0.0.1:4567" },
+    "diagnostics": { "$ref": "#/$defs/Diagnostics" },
+    "phases": {
+      "type": "array",
+      "minItems": 1,
+      "items": { "$ref": "#/$defs/Phase" }
+    }
+  },
+  "$defs": {
+    "Phase": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "ok", "detail", "error"],
+      "properties": {
+        "name": { "type": "string", "minLength": 1 },
+        "ok": { "type": "boolean" },
+        "detail": { "type": ["string", "null"] },
+        "error": { "type": ["string", "null"] }
+      }
+    },
+    "Diagnostics": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["schema", "schema_version", "ok", "platform", "results"],
+      "properties": {
+        "schema": { "const": "hive-setup-diagnostics" },
+        "schema_version": { "const": 1 },
+        "ok": { "type": "boolean" },
+        "platform": { "type": "string" },
+        "results": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/DiagnosticResult" }
+        }
+      }
+    },
+    "DiagnosticResult": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "status", "detail", "fix", "bootstrappable", "path", "version"],
+      "properties": {
+        "name": { "type": "string", "minLength": 1 },
+        "status": {
+          "type": "string",
+          "enum": ["ok", "missing", "outdated", "unauthenticated", "bootstrappable", "error", "unsupported"]
+        },
+        "detail": { "type": ["string", "null"] },
+        "fix": { "type": ["string", "null"] },
+        "bootstrappable": { "type": "boolean" },
+        "path": { "type": ["string", "null"] },
+        "version": { "type": ["string", "null"] }
+      }
+    }
+  }
+}
diff --git a/schemas/hive-web-install.v1.json b/schemas/hive-web-install.v1.json
new file mode 100644
index 00000000..c2a7d940
--- /dev/null
+++ b/schemas/hive-web-install.v1.json
@@ -0,0 +1,49 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-install.v1.json",
+  "title": "hive web install output (v1)",
+  "description": "Stable contract emitted by `hive web install --json` and `hive web install --force --json`.",
+  "oneOf": [
+    { "$ref": "#/$defs/SuccessPayload" },
+    { "$ref": "#/$defs/ErrorPayload" }
+  ],
+  "$defs": {
+    "SuccessPayload": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "schema", "schema_version", "ok", "outcome", "platform",
+        "unit_path", "backup_path", "restarted", "messages"
+      ],
+      "properties": {
+        "schema": { "const": "hive-web-install" },
+        "schema_version": { "const": 1 },
+        "ok": { "const": true },
+        "outcome": { "type": "string", "enum": ["written", "upgraded", "unchanged", "unsupported"] },
+        "platform": { "type": "string", "enum": ["linux", "macos", "unsupported"] },
+        "unit_path": { "type": ["string", "null"] },
+        "backup_path": { "type": ["string", "null"] },
+        "restarted": { "type": "boolean" },
+        "messages": { "type": "array", "items": { "type": "string" } }
+      }
+    },
+    "ErrorPayload": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "schema", "schema_version", "ok", "outcome", "platform",
+        "unit_path", "messages", "error"
+      ],
+      "properties": {
+        "schema": { "const": "hive-web-install" },
+        "schema_version": { "const": 1 },
+        "ok": { "const": false },
+        "outcome": { "type": "string", "enum": ["drifted", "failed"] },
+        "platform": { "type": "string", "enum": ["linux", "macos", "unsupported"] },
+        "unit_path": { "type": ["string", "null"] },
+        "messages": { "type": "array", "items": { "type": "string" } },
+        "error": { "type": "string", "minLength": 1 }
+      }
+    }
+  }
+}
diff --git a/schemas/hive-web-status.v1.json b/schemas/hive-web-status.v1.json
new file mode 100644
index 00000000..fc9b8d6e
--- /dev/null
+++ b/schemas/hive-web-status.v1.json
@@ -0,0 +1,44 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-status.v1.json",
+  "title": "hive web status output (v1)",
+  "description": "Stable contract emitted by `hive web status --json`. Inactive or missing services emit ok=false and exit non-zero while preserving the complete service snapshot.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "schema",
+    "schema_version",
+    "ok",
+    "platform",
+    "service_installed",
+    "service_enabled",
+    "service_active",
+    "unit_path",
+    "error"
+  ],
+  "properties": {
+    "schema": { "const": "hive-web-status" },
+    "schema_version": { "const": 1 },
+    "ok": { "type": "boolean" },
+    "platform": { "type": "string", "enum": ["linux", "macos", "unsupported"] },
+    "service_installed": { "type": "boolean" },
+    "service_enabled": { "type": "boolean" },
+    "service_active": { "type": "boolean" },
+    "unit_path": { "type": ["string", "null"] },
+    "error": { "type": ["string", "null"] }
+  },
+  "allOf": [
+    {
+      "if": { "properties": { "service_active": { "const": true } }, "required": ["service_active"] },
+      "then": {
+        "properties": { "ok": { "const": true }, "error": { "type": "null" } }
+      },
+      "else": {
+        "properties": {
+          "ok": { "const": false },
+          "error": { "const": "web service not active" }
+        }
+      }
+    }
+  ]
+}
diff --git a/test/integration/daemon_stale_agent_healing_test.rb b/test/integration/daemon_stale_agent_healing_test.rb
index eeb25d12..d3c7999e 100644
--- a/test/integration/daemon_stale_agent_healing_test.rb
+++ b/test/integration/daemon_stale_agent_healing_test.rb
@@ -123,7 +123,9 @@ class DaemonStaleAgentHealingTest < Minitest::Test
         request = pending.find { |r| r.slug == slug }
         refute_nil request,
                    "the rerun must land in the REAL queue (argv allowlist, slug grammar, kwargs contract)"
-        assert Hive::Daemon::DispatchRequestQueue.valid_argv?(request.argv),
+        assert Hive::Daemon::DispatchRequestQueue.valid_argv?(
+          request.argv, project: request.project, slug: request.slug
+        ),
                "the queued argv must pass the dispatcher's allowlist"
         assert_equal [ "hive", "plan", slug, "--project", File.basename(dir), "--from", "3-plan" ],
                      request.argv
diff --git a/test/integration/local_web_setup_e2e_test.rb b/test/integration/local_web_setup_e2e_test.rb
new file mode 100644
index 00000000..2cf89575
--- /dev/null
+++ b/test/integration/local_web_setup_e2e_test.rb
@@ -0,0 +1,466 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "json"
+require "cgi"
+require "net/http"
+require "stringio"
+require "uri"
+require "hive/commands/setup"
+require "hive/setup/diagnostics"
+require "hive/commands/web/service_installer"
+require "hive/commands/daemon/service_installer"
+require "hive/daemon/status_report"
+require "hive/web/app_bundle"
+require "hive/web/dispatcher"
+require "hive/daemon/dispatch_request_queue"
+require "hive/commands/service_installer/outcome"
+require "hive/tui/bubble_model"
+require "hive/tui/state_source"
+
+# Hermetic local-setup acceptance: fake package/service managers, real temp
+# git-shaped project paths, shared XDG state. Proves setup composition,
+# common binary selection, independent web/daemon units, and repair enqueue.
+class LocalWebSetupE2ETest < Minitest::Test
+  include HiveTestHelper
+
+  REPO_ROOT = File.expand_path("../..", __dir__)
+  FAKE_AGENT_BIN = File.join(REPO_ROOT, "web", "test", "e2e", "support")
+  LIVE_E2E_ENV = "HIVE_LIVE_LOCAL_WEB_E2E"
+
+  # A service-manager-shaped adapter that starts the production commands as
+  # real foreground process groups. It keeps the acceptance hermetic while
+  # proving the same current binary can boot independent daemon and Rails
+  # services without writing to the host's systemd/launchd state.
+  class LiveProcessInstaller
+    attr_reader :messages, :target_path, :pid
+
+    def initialize(name:, binary_path:, args:, log_path:)
+      @name = name
+      @binary_path = binary_path
+      @args = args
+      @log_path = log_path
+      @target_path = "#{log_path}.service"
+      @messages = []
+      @pid = nil
+    end
+
+    def install!(autostart:, force: false)
+      File.write(target_path, "ExecStart=#{@binary_path} #{@args.join(' ')}\n")
+      start_process! if autostart
+      Hive::Commands::ServiceInstaller::Outcome.new(:written)
+    end
+
+    def status_snapshot
+      {
+        "platform" => "linux",
+        "unit_path" => target_path,
+        "service_installed" => File.file?(target_path),
+        "service_enabled" => !@pid.nil?,
+        "service_active" => process_alive?
+      }
+    end
+
+    def restart!
+      stop!
+      start_process!
+      :ok
+    rescue SystemCallError => e
+      @messages << "#{@name} restart failed: #{e.message}"
+      :failed
+    end
+
+    def stop!
+      return unless @pid
+
+      Process.kill("TERM", -@pid)
+      Process.wait(@pid)
+    rescue Errno::ESRCH, Errno::ECHILD
+      nil
+    ensure
+      @pid = nil
+    end
+
+    private
+
+    def start_process!
+      FileUtils.mkdir_p(File.dirname(@log_path))
+      log = File.open(@log_path, "w")
+      @pid = Process.spawn(@binary_path, *@args, pgroup: true, out: log, err: log)
+      log.close
+    end
+
+    def process_alive?
+      return false unless @pid
+
+      Process.kill(0, @pid)
+      true
+    rescue Errno::ESRCH
+      false
+    end
+  end
+
+  # Setup calls `ensure_installed!` without a source because production uses a
+  # release download. The acceptance uses the checked-out release tree but the
+  # real AppBundle staging, Bundler install, version stamp, and atomic swap.
+  class LocalReleaseBundle < Hive::Web::AppBundle
+    def initialize(source:, **kwargs)
+      @local_source = source
+      super(**kwargs)
+    end
+
+    def ensure_installed!(source: nil)
+      super(source: source || @local_source)
+    end
+  end
+
+  def healthy_results
+    %w[ruby git tmux gh claude codex node npm qmd web_bundle sqlite].map do |name|
+      Hive::Setup::Diagnostics::Result.new(
+        name: name, status: "ok", detail: "#{name} ok",
+        fix: nil, bootstrappable: false
+      )
+    end
+  end
+
+  def test_setup_service_installs_both_units_with_one_binary
+    with_xdg_home do |home|
+      project = File.join(home, "proj")
+      FileUtils.mkdir_p(project)
+      # Pretend already initialized so enroll path is taken.
+      FileUtils.mkdir_p(File.join(project, ".hive-state"))
+
+      hive_bin = File.join(home, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(hive_bin))
+      File.write(hive_bin, "#!/bin/sh\n")
+      FileUtils.chmod(0o755, hive_bin)
+
+      daemon_cmds = []
+      web_cmds = []
+      daemon = Hive::Commands::Daemon::ServiceInstaller.new(
+        host_os: "linux",
+        home: home,
+        binary_path: hive_bin,
+        systemctl_available: true,
+        runner: ->(argv) { daemon_cmds << argv; true }
+      )
+      web = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: home,
+        binary_path: hive_bin,
+        systemctl_available: true,
+        runner: ->(argv) { web_cmds << argv; true }
+      )
+
+      report = Hive::Setup::Diagnostics::Report.new(
+        results: healthy_results, platform: "linux", ok: true
+      )
+      probe = Object.new
+      probe.define_singleton_method(:call) { report }
+
+      out = StringIO.new
+      cmd = Hive::Commands::Setup.new(
+        project_path: project,
+        service: true,
+        json: true,
+        binary_path: hive_bin,
+        diagnostics: probe,
+        daemon_installer: daemon,
+        web_installer: web,
+        init_runner: ->(*) { flunk "should enroll, not init" },
+        enroll_runner: ->(path) {
+          Hive::Config.register_project(name: "proj", path: path)
+          "enrolled"
+        },
+        qmd_installer: -> { "qmd ok" },
+        app_bundle: Hive::Web::AppBundle.new(
+          data_home: File.join(home, "data", "hive"),
+          env: {},
+          source_app_dir: File.join(home, "nope"),
+          downloader: ->(*) { },
+          bundler: ->(*) { }
+        ).tap { |b|
+          FileUtils.mkdir_p(File.join(b.managed_app_dir, "config"))
+          File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n")
+          File.write(b.version_stamp_path, "#{Hive::VERSION}\n")
+        },
+        output: out,
+        err: StringIO.new
+      )
+
+      assert cmd.call
+      payload = JSON.parse(out.string)
+      assert payload["ok"], payload.inspect
+      assert_equal hive_bin, payload["binary_path"]
+      assert_equal "http://127.0.0.1:4567", payload["web_url"]
+
+      daemon_unit = File.read(daemon.target_path)
+      web_unit = File.read(web.target_path)
+      assert_includes daemon_unit, "ExecStart=#{hive_bin} daemon start"
+      assert_includes web_unit, "ExecStart=#{hive_bin} web"
+      refute_equal daemon.target_path, web.target_path
+
+      # Drift: rewrite daemon unit to wrong binary, status reports path drift.
+      wrong = File.join(home, "wrong-hive")
+      File.write(wrong, "#!/bin/sh\n")
+      FileUtils.chmod(0o755, wrong)
+      File.write(
+        daemon.target_path,
+        daemon_unit.gsub("ExecStart=#{hive_bin} daemon start", "ExecStart=#{wrong} daemon start")
+      )
+      assert_equal wrong, daemon.installed_binary_path
+      assert_equal hive_bin, daemon.resolved_binary_for_status
+      status = Hive::Daemon::StatusReport.new(
+        hive_home: Hive::Paths.state_home,
+        binary_path: hive_bin,
+        installer: daemon,
+        version_probe: ->(*) { Hive::VERSION }
+      ).to_h
+      assert_equal "path", status["binary_drift"], status.inspect
+
+      # Repair enqueue uses exact allowlist.
+      request_id = Hive::Web::Dispatcher.new.queue_daemon_repair!
+      files = Dir[File.join(Hive::Paths.state_home, "dispatch_requests", "*.json")]
+      body = files.map { |f| File.read(f) }.find { |t| t.include?(request_id) }
+      parsed = JSON.parse(body)
+      assert_equal %w[hive daemon install --force], parsed["argv"]
+      assert_equal "__hive_host__", parsed["project"]
+    end
+  end
+
+  def test_foreground_and_managed_share_same_state_home
+    with_xdg_home do |_home|
+      state = Hive::Paths.state_home
+      assert_equal File.join(state, "web-storage"),
+                   File.join(Hive::Paths.state_home, "web-storage")
+      # Config defaults bind loopback for local mode.
+      cfg = Hive::Config.load_global_web
+      assert_equal "127.0.0.1", cfg["bind"]
+      assert_equal true, cfg["local_loopback"]
+    end
+  end
+
+  def test_live_fresh_setup_and_bidirectional_tui_web_dispatch
+    skip "set #{LIVE_E2E_ENV}=1 to run the live Rails/daemon acceptance" unless ENV[LIVE_E2E_ENV] == "1"
+
+    with_xdg_home do |sandbox|
+      with_tmp_git_repo do |project_root|
+        prepare_fast_daemon_config!
+        prepare_local_release_gem!
+        hive_bin = write_current_binary_wrapper!(sandbox)
+        app_bundle = LocalReleaseBundle.new(
+          source: File.join(REPO_ROOT, "web"),
+          data_home: Hive::Paths.data_home,
+          source_app_dir: File.join(sandbox, "missing-source-checkout")
+        )
+        daemon = LiveProcessInstaller.new(
+          name: "daemon", binary_path: hive_bin,
+          args: %w[daemon start --foreground],
+          log_path: File.join(sandbox, "daemon-service.log")
+        )
+        web = LiveProcessInstaller.new(
+          name: "web", binary_path: hive_bin, args: %w[web],
+          log_path: File.join(sandbox, "web-service.log")
+        )
+
+        with_env("PATH" => [ File.dirname(hive_bin), FAKE_AGENT_BIN, ENV.fetch("PATH", "") ].join(File::PATH_SEPARATOR)) do
+          report = live_diagnostics_report
+          probe = Object.new
+          probe.define_singleton_method(:call) { report }
+          output = StringIO.new
+
+          begin
+            assert Hive::Commands::Setup.new(
+              project_path: project_root,
+              service: true,
+              json: true,
+              binary_path: hive_bin,
+              diagnostics: probe,
+              app_bundle: app_bundle,
+              daemon_installer: daemon,
+              web_installer: web,
+              output: output,
+              err: StringIO.new
+            ).call
+
+            payload = JSON.parse(output.string)
+            assert payload["ok"], payload.inspect
+            assert_equal hive_bin, payload["binary_path"]
+            assert File.directory?(File.join(project_root, ".hive-state")),
+                   "setup must initialize a genuinely fresh repository"
+            assert_equal project_root, Hive::Config.find_project(File.basename(project_root)).fetch("path")
+            assert daemon.status_snapshot["service_active"], "daemon foreground service must be alive"
+            assert web.status_snapshot["service_active"], "Rails foreground service must be alive"
+            wait_for_http!("http://127.0.0.1:4567/health?deep=1")
+
+            force_headless_agent!(project_root)
+            assert_bidirectional_parity!(project_root)
+          ensure
+            web.stop!
+            daemon.stop!
+          end
+        end
+      end
+    end
+  end
+
+  private
+
+  def live_diagnostics_report
+    results = healthy_results.map do |result|
+      next result unless result.name == "web_bundle"
+
+      Hive::Setup::Diagnostics::Result.new(
+        name: "web_bundle", status: "bootstrappable",
+        detail: "install local release bundle", fix: "hive setup",
+        bootstrappable: true
+      )
+    end
+    Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: true)
+  end
+
+  def prepare_fast_daemon_config!
+    FileUtils.mkdir_p(Hive::Paths.config_home)
+    File.write(
+      Hive::Config.global_config_path,
+      {
+        "registered_projects" => [],
+        "daemon" => {
+          "poll_interval_sec" => 5,
+          "fast_poll_sec" => 1,
+          "edit_debounce_sec" => 1
+        }
+      }.to_yaml
+    )
+  end
+
+  def prepare_local_release_gem!
+    destination = Hive::Paths.data_home
+    FileUtils.mkdir_p(destination)
+    %w[bin lib templates schemas examples].each do |entry|
+      source = File.join(REPO_ROOT, entry)
+      FileUtils.cp_r(source, File.join(destination, entry)) if File.exist?(source)
+    end
+    %w[hive.gemspec install.md CHANGELOG.md LICENSE README.md].each do |entry|
+      FileUtils.cp(File.join(REPO_ROOT, entry), File.join(destination, entry))
+    end
+  end
+
+  def write_current_binary_wrapper!(sandbox)
+    path = File.join(sandbox, "bin", "hive")
+    root_bundle_path = ENV.fetch(
+      "LOCAL_WEB_E2E_ROOT_BUNDLE_PATH",
+      File.join(REPO_ROOT, "vendor", "root-bundle")
+    )
+    FileUtils.mkdir_p(File.dirname(path))
+    File.write(path, <<~SH)
+      #!/usr/bin/env bash
+      exec env \
+        BUNDLE_GEMFILE=#{Shellwords.escape(File.join(REPO_ROOT, 'Gemfile'))} \
+        BUNDLE_PATH=#{Shellwords.escape(root_bundle_path)} \
+        BUNDLE_DEPLOYMENT= BUNDLE_FROZEN= \
+        bundle exec ruby -I#{Shellwords.escape(File.join(REPO_ROOT, 'lib'))} \
+        #{Shellwords.escape(File.join(REPO_ROOT, 'bin', 'hive'))} "$@"
+    SH
+    FileUtils.chmod(0o755, path)
+    path
+  end
+
+  def force_headless_agent!(project_root)
+    path = File.join(project_root, ".hive-state", "config.yml")
+    config = YAML.safe_load_file(path)
+    config["claude"] = (config["claude"] || {}).merge("mode" => "headless")
+    config["execute"] = (config["execute"] || {}).merge("agent" => "claude")
+    File.write(path, config.to_yaml)
+  end
+
+  def assert_bidirectional_parity!(project_root)
+    project = File.basename(project_root)
+    tui_title = "Live TUI parity idea"
+    snapshot = Hive::Tui::StateSource.new.refresh_now
+    bubble = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial.with(
+        mode: :new_idea,
+        snapshot: snapshot,
+        scope: 0,
+        new_idea_project_name: project,
+        new_idea_buffer: tui_title,
+        new_idea_cursor: tui_title.length
+      )
+    )
+    capture_io { bubble.update(Hive::Tui::Messages::NEW_IDEA_SUBMITTED) }
+    assert_match(/\A\+ /, bubble.hive_model.flash.to_s)
+    tui_slug = wait_for_task_slug!(project_root, tui_title)
+    assert_includes wait_for_http!("http://127.0.0.1:4567/"), tui_title
+    wait_for_daemon_dispatch!(tui_slug)
+
+    web_title = "Live web parity idea"
+    create_web_idea!(project: project, title: web_title)
+    web_slug = wait_for_task_slug!(project_root, web_title)
+    web_snapshot = Hive::Tui::StateSource.new.refresh_now
+    assert web_snapshot.rows.any? { |row| row.slug == web_slug && row.display_name == web_title },
+           "web-created task must be visible through the TUI state source"
+    wait_for_daemon_dispatch!(web_slug)
+  end
+
+  def create_web_idea!(project:, title:)
+    uri = URI("http://127.0.0.1:4567/")
+    response = Net::HTTP.get_response(uri)
+    assert response.is_a?(Net::HTTPSuccess), response.inspect
+    token = response.body[/<meta name="csrf-token" content="([^"]+)"/, 1]
+    refute_nil token, "Rails root must expose a CSRF token"
+    cookie = Array(response.get_fields("set-cookie")).map { |value| value.split(";", 2).first }.join("; ")
+
+    request = Net::HTTP::Post.new("/ideas")
+    request["Cookie"] = cookie unless cookie.empty?
+    request.set_form_data(
+      "authenticity_token" => CGI.unescapeHTML(token),
+      "project" => project,
+      "text" => title
+    )
+    result = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) }
+    assert result.is_a?(Net::HTTPRedirection), result.inspect
+  end
+
+  def wait_for_http!(url, timeout: 45)
+    uri = URI(url)
+    wait_until!(timeout: timeout, failure: "#{url} did not become healthy") do
+      response = Net::HTTP.get_response(uri)
+      response.body if response.is_a?(Net::HTTPSuccess)
+    rescue IOError, SystemCallError, SocketError, Timeout::Error
+      nil
+    end
+  end
+
+  def wait_for_task_slug!(project_root, title, timeout: 20)
+    wait_until!(timeout: timeout, failure: "task #{title.inspect} never appeared") do
+      folder = Dir[File.join(project_root, ".hive-state", "stages", "*", "*")].find do |candidate|
+        idea = File.join(candidate, "idea.md")
+        File.file?(idea) && File.read(idea).include?(title)
+      end
+      File.basename(folder) if folder
+    end
+  end
+
+  def wait_for_daemon_dispatch!(slug, timeout: 30)
+    log_path = File.join(Hive::Paths.state_home, "logs", "daemon.log")
+    wait_until!(timeout: timeout, failure: "daemon never auto-dispatched #{slug}") do
+      next unless File.file?(log_path)
+
+      File.foreach(log_path).any? do |line|
+        line.include?(slug) && line.include?('"event":"dispatched"')
+      end
+    end
+  end
+
+  def wait_until!(timeout:, failure:)
+    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+    loop do
+      result = yield
+      return result if result
+      raise failure if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+
+      sleep 0.1
+    end
+  end
+end
diff --git a/test/unit/bounded_subprocess_test.rb b/test/unit/bounded_subprocess_test.rb
new file mode 100644
index 00000000..307e8129
--- /dev/null
+++ b/test/unit/bounded_subprocess_test.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/bounded_subprocess"
+require "rbconfig"
+
+class BoundedSubprocessTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_capture3_returns_output_and_status
+    out, err, status = Hive::BoundedSubprocess.capture3(
+      RbConfig.ruby, "-e", '$stdout.write("out"); $stderr.write("err")', timeout: 2
+    )
+
+    assert_equal "out", out
+    assert_equal "err", err
+    assert status.success?
+  end
+
+  def test_timeout_terminates_and_reaps_the_process_group
+    with_tmp_dir do |dir|
+      child_pid_path = File.join(dir, "child.pid")
+      script = <<~RUBY
+        child = spawn(#{RbConfig.ruby.inspect}, "-e", "sleep 30")
+        File.write(#{child_pid_path.inspect}, child)
+        sleep 30
+      RUBY
+
+      error = assert_raises(Hive::BoundedSubprocess::TimeoutError) do
+        Hive::BoundedSubprocess.capture3(RbConfig.ruby, "-e", script, timeout: 1.0)
+      end
+
+      assert_operator error.elapsed, :>=, 1.0
+      child_pid = Integer(File.read(child_pid_path))
+      refute process_alive?(child_pid), "timeout must kill descendants in the spawned process group"
+    end
+  end
+
+  private
+
+  def process_alive?(pid)
+    Process.kill(0, pid)
+    true
+  rescue Errno::ESRCH
+    false
+  end
+end
diff --git a/test/unit/commands/daemon_test.rb b/test/unit/commands/daemon_test.rb
index f87b788e..17984300 100644
--- a/test/unit/commands/daemon_test.rb
+++ b/test/unit/commands/daemon_test.rb
@@ -164,6 +164,36 @@ class HiveCommandsDaemonTest < Minitest::Test
                  "answer_digest.hour must be forwarded into the scheduler"
   end
 
+  def test_start_daemon_wires_current_service_installer_binary_for_repairs
+    require "hive/commands/daemon/service_installer"
+
+    command = daemon("start", dry_run: true)
+    dispatcher = FakeDispatcher.new([])
+    resolved = nil
+    installer = Object.new
+    installer.define_singleton_method(:resolved_binary_for_status) { "/current/bin/hive" }
+
+    with_replaced_singleton_method(Hive::Lock, :process_start_time, ->(pid) { "start-#{pid}" }) do
+      with_global_start_config(daemon_config) do
+        with_replaced_singleton_method(
+          Hive::Commands::Daemon::ServiceInstaller,
+          :new,
+          ->(**_kwargs) { installer }
+        ) do
+          with_replaced_singleton_method(Hive::Daemon::Dispatcher, :new, lambda { |**kwargs|
+            supervisor = kwargs.fetch(:supervisor)
+            resolved = supervisor.send(:resolved_maintenance_binary)
+            dispatcher
+          }) do
+            command.call
+          end
+        end
+      end
+    end
+
+    assert_equal "/current/bin/hive", resolved
+  end
+
   def test_start_daemon_invokes_reexec_when_dispatcher_signals_drift
     command = daemon("start", dry_run: true)
     dispatcher = FakeDispatcher.new([], true) # signals drift
@@ -345,6 +375,9 @@ class HiveCommandsDaemonTest < Minitest::Test
     }
     fake = Struct.new(:state) do
       def service_state = state
+      def resolved_binary_for_status = "/opt/hive/bin/hive"
+      def installed_binary_path = "/opt/hive/bin/hive"
+      def binary_parse_error = nil
     end.new(state)
     require "hive/commands/daemon/service_installer"
     out, _err = with_replaced_singleton_method(
@@ -359,6 +392,9 @@ class HiveCommandsDaemonTest < Minitest::Test
     # Existing fields must survive the merge.
     assert_equal true, doc.fetch("running")
     assert_equal 1234, doc.fetch("pid")
+    assert_equal "unreadable", doc.fetch("binary_drift"),
+                 "a configured binary that cannot be executed must remain actionable"
+    assert_equal "/opt/hive/bin/hive", doc.fetch("expected_binary")
 
     require "json_schemer"
     schema = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-daemon-status"))))
@@ -383,6 +419,7 @@ class HiveCommandsDaemonTest < Minitest::Test
     assert_nil doc.fetch("service_enabled")
     assert_nil doc.fetch("unit_path")
     assert_equal true, doc.fetch("running")
+    assert_equal "unreadable", doc.fetch("binary_drift")
 
     require "json_schemer"
     schema = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-daemon-status"))))
diff --git a/test/unit/commands/setup/exit_code_test.rb b/test/unit/commands/setup/exit_code_test.rb
new file mode 100644
index 00000000..c07593ff
--- /dev/null
+++ b/test/unit/commands/setup/exit_code_test.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/commands/setup"
+require "hive/setup/diagnostics"
+require "stringio"
+
+class SetupExitCodeTest < Minitest::Test
+  include HiveTestHelper
+
+  def diag(ok:)
+    results = [
+      Hive::Setup::Diagnostics::Result.new(
+        name: "ruby", status: "ok", detail: "ok", fix: nil, bootstrappable: false
+      )
+    ]
+    unless ok
+      results << Hive::Setup::Diagnostics::Result.new(
+        name: "gh", status: "unauthenticated", detail: "need login",
+        fix: "gh auth login", bootstrappable: false
+      )
+    end
+    # Pad to avoid empty hard-failure edge cases on other names
+    %w[git tmux claude codex node npm qmd web_bundle sqlite].each do |n|
+      results << Hive::Setup::Diagnostics::Result.new(
+        name: n, status: "ok", detail: "ok", fix: nil, bootstrappable: false
+      )
+    end
+    Hive::Setup::Diagnostics::Report.new(
+      results: results,
+      platform: "linux",
+      ok: ok
+    )
+  end
+
+  def test_success_does_not_raise
+    out = StringIO.new
+    report = diag(ok: true)
+    probe = Object.new
+    probe.define_singleton_method(:call) { report }
+    cmd = Hive::Commands::Setup.new(
+      project_path: Dir.mktmpdir,
+      no_bootstrap: true,
+      json: true,
+      diagnostics: probe,
+      output: out,
+      err: StringIO.new
+    )
+    assert cmd.call
+    assert JSON.parse(out.string)["ok"]
+  end
+
+  def test_hard_diagnostic_failure_raises_after_json_emit
+    out = StringIO.new
+    report = diag(ok: false)
+    probe = Object.new
+    probe.define_singleton_method(:call) { report }
+    cmd = Hive::Commands::Setup.new(
+      project_path: Dir.mktmpdir,
+      no_bootstrap: true,
+      json: true,
+      diagnostics: probe,
+      output: out,
+      err: StringIO.new
+    )
+    assert_raises(Hive::Error) { cmd.call }
+    payload = JSON.parse(out.string)
+    refute payload["ok"]
+    assert payload["diagnostics"]["results"].any? { |r| r["fix"] == "gh auth login" }
+  end
+end
diff --git a/test/unit/commands/setup/orchestrator_test.rb b/test/unit/commands/setup/orchestrator_test.rb
new file mode 100644
index 00000000..a7157ba9
--- /dev/null
+++ b/test/unit/commands/setup/orchestrator_test.rb
@@ -0,0 +1,373 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/commands/setup"
+require "hive/setup/diagnostics"
+require "stringio"
+
+class SetupOrchestratorTest < Minitest::Test
+  include HiveTestHelper
+
+  def healthy_diag
+    results = %w[
+      ruby git tmux gh claude codex node npm qmd web_bundle sqlite
+    ].map do |name|
+      Hive::Setup::Diagnostics::Result.new(
+        name: name, status: "ok", detail: "#{name} ok", fix: nil, bootstrappable: false
+      )
+    end
+    Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: true)
+  end
+
+  def bootstrappable_diag
+    results = healthy_diag.results.map do |r|
+      if r.name == "qmd" || r.name == "web_bundle"
+        Hive::Setup::Diagnostics::Result.new(
+          name: r.name, status: "bootstrappable", detail: "need install",
+          fix: "install", bootstrappable: true
+        )
+      else
+        r
+      end
+    end
+    Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: true)
+  end
+
+  def unauth_diag
+    results = healthy_diag.results.map do |r|
+      if %w[gh claude codex].include?(r.name)
+        Hive::Setup::Diagnostics::Result.new(
+          name: r.name, status: "unauthenticated", detail: "need login",
+          fix: "#{r.name} login", bootstrappable: false
+        )
+      else
+        r
+      end
+    end
+    Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: false)
+  end
+
+  FakeInstaller = Struct.new(:target, :kind, :messages, keyword_init: true) do
+    def install!(autostart:, force: false)
+      Hive::Commands::ServiceInstaller::Outcome.new(kind)
+    end
+
+    def target_path = target
+    def messages = self[:messages] || []
+    def status_snapshot = { "service_active" => false }
+    def restart! = :ok
+  end
+
+  def build(dir, **opts)
+    out = StringIO.new
+    err = StringIO.new
+    diag = opts.delete(:diag) || healthy_diag
+    qmd_calls = []
+    web_calls = []
+    daemon_calls = []
+    web_svc_calls = []
+    init_calls = []
+    enroll_calls = []
+
+    cmd = Hive::Commands::Setup.new(
+      project_path: opts.delete(:project_path) || dir,
+      service: opts.fetch(:service, false),
+      no_bootstrap: opts.fetch(:no_bootstrap, false),
+      no_init: opts.fetch(:no_init, false),
+      json: opts.fetch(:json, false),
+      binary_path: opts.delete(:binary_path) || "/opt/hive/bin/hive",
+      diagnostics: -> { diag }.then { |p| Object.new.tap { |o| o.define_singleton_method(:call) { diag } } },
+      app_bundle: Object.new.tap { |o|
+        o.define_singleton_method(:ensure_installed!) {
+          web_calls << true
+          File.join(dir, "web-app")
+        }
+      },
+      qmd_installer: -> {
+        qmd_calls << true
+        "qmd installed"
+      },
+      daemon_installer: FakeInstaller.new(target: "/u/hive-daemon.service", kind: :written).tap { |i|
+        i.define_singleton_method(:install!) { |**|
+          daemon_calls << true
+          Hive::Commands::ServiceInstaller::Outcome.new(:written)
+        }
+      },
+      web_installer: FakeInstaller.new(target: "/u/hive-web.service", kind: :written).tap { |i|
+        i.define_singleton_method(:install!) { |**|
+          web_svc_calls << true
+          Hive::Commands::ServiceInstaller::Outcome.new(:written)
+        }
+      },
+      init_runner: ->(path) { init_calls << path; "initialized" },
+      enroll_runner: ->(path) { enroll_calls << path; "enrolled" },
+      output: out,
+      err: err
+    )
+    {
+      cmd: cmd, out: out, err: err,
+      qmd_calls: qmd_calls, web_calls: web_calls,
+      daemon_calls: daemon_calls, web_svc_calls: web_svc_calls,
+      init_calls: init_calls, enroll_calls: enroll_calls
+    }
+  end
+
+  def test_fresh_setup_service_bootstraps_and_reports_url
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: true, diag: bootstrappable_diag, json: true)
+      capture_io { ctx[:cmd].call }
+      payload = JSON.parse(ctx[:out].string)
+      assert payload["ok"], payload.inspect
+      assert_equal true, payload["service"]
+      assert_equal "http://127.0.0.1:4567", payload["web_url"]
+      assert_equal [ true ], ctx[:qmd_calls]
+      assert_equal [ true ], ctx[:web_calls]
+      assert_equal [ true ], ctx[:daemon_calls]
+      assert_equal [ true ], ctx[:web_svc_calls]
+      assert_equal [ dir ], ctx[:init_calls]
+      phase_names = payload["phases"].map { |p| p["name"] }
+      assert_includes phase_names, "bootstrap_qmd"
+      assert_includes phase_names, "bootstrap_web_bundle"
+      assert_includes phase_names, "daemon_install"
+      assert_includes phase_names, "web_install"
+    end
+  end
+
+  def test_rerun_on_initialized_repo_uses_enroll_path
+    with_tmp_dir do |dir|
+      FileUtils.mkdir_p(File.join(dir, ".hive-state"))
+      ctx = build(dir, service: false)
+      capture_io { ctx[:cmd].call }
+      assert_empty ctx[:init_calls]
+      assert_equal [ dir ], ctx[:enroll_calls]
+      refute ctx[:web_svc_calls].any?
+    end
+  end
+
+  def test_no_bootstrap_is_diagnose_only
+    with_tmp_dir do |dir|
+      ctx = build(dir, no_bootstrap: true, service: true, diag: bootstrappable_diag, json: true)
+      capture_io { ctx[:cmd].call }
+      payload = JSON.parse(ctx[:out].string)
+      assert payload["ok"]
+      assert_empty ctx[:qmd_calls]
+      assert_empty ctx[:web_calls]
+      assert_empty ctx[:daemon_calls]
+      assert_empty ctx[:web_svc_calls]
+      assert_empty ctx[:init_calls]
+      assert_equal [ "diagnostics" ], payload["phases"].map { |p| p["name"] }
+    end
+  end
+
+  def test_no_init_still_provisions_services
+    with_tmp_dir do |dir|
+      ctx = build(dir, no_init: true, service: true)
+      capture_io { ctx[:cmd].call }
+      assert_equal [ true ], ctx[:daemon_calls]
+      assert_equal [ true ], ctx[:web_svc_calls]
+      assert_empty ctx[:init_calls]
+      assert_empty ctx[:enroll_calls]
+    end
+  end
+
+  def test_missing_auth_blocks_success_and_emits_fix_commands
+    with_tmp_dir do |dir|
+      ctx = build(dir, diag: unauth_diag, json: true, service: true)
+      error = assert_raises(Hive::Error) { ctx[:cmd].call }
+      assert_match(/diagnostics failed/, error.message)
+      payload = JSON.parse(ctx[:out].string)
+      refute payload["ok"]
+      fixes = payload["diagnostics"]["results"].select { |r| r["status"] == "unauthenticated" }.map { |r| r["fix"] }
+      assert_includes fixes, "gh login"
+      assert_includes fixes, "claude login"
+      assert_includes fixes, "codex login"
+      # Still ran provisioning phases for visibility (diag failed but bootstrap continues)
+      assert ctx[:daemon_calls].any? || payload["phases"].any?
+    end
+  end
+
+  def test_phase_failure_keeps_full_json_and_nonzero
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: true, json: true)
+      ctx[:cmd].instance_variable_set(:@daemon_installer, Object.new.tap { |o|
+        o.define_singleton_method(:install!) { |**| raise "systemctl down" }
+        o.define_singleton_method(:target_path) { "/u" }
+        o.define_singleton_method(:messages) { [] }
+      })
+      error = assert_raises(Hive::Error) { ctx[:cmd].call }
+      assert_match(/daemon_install|systemctl/, error.message)
+      payload = JSON.parse(ctx[:out].string)
+      refute payload["ok"]
+      daemon_phase = payload["phases"].find { |p| p["name"] == "daemon_install" }
+      refute daemon_phase["ok"]
+      # Later phases still recorded when possible
+      assert payload["phases"].any? { |p| p["name"] == "web_install" } ||
+             payload["phases"].any? { |p| p["name"] == "init" }
+    end
+  end
+
+  def test_init_system_exit_is_recorded_and_later_service_phase_still_runs
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: true, json: true)
+      ctx[:cmd].instance_variable_set(:@init_runner, ->(_path) { raise SystemExit.new(64) })
+
+      error = assert_raises(Hive::Error) { ctx[:cmd].call }
+      assert_match(/init.*status 64/, error.message)
+
+      payload = JSON.parse(ctx[:out].string)
+      init_phase = payload["phases"].find { |phase| phase["name"] == "init" }
+      refute init_phase["ok"]
+      assert_match(/status 64/, init_phase["error"])
+      assert_equal [ true ], ctx[:web_svc_calls], "SystemExit must not skip later setup phases"
+    end
+  end
+
+  def test_default_init_uses_non_emitting_programmatic_mode
+    with_tmp_dir do |dir|
+      require "hive/commands/init"
+      options = nil
+      fake = Object.new.tap { |object| object.define_singleton_method(:call) { true } }
+      command = Hive::Commands::Setup.new(project_path: dir)
+
+      replacement = lambda do |_path, **kwargs|
+        options = kwargs
+        fake
+      end
+      with_replaced_singleton_method(Hive::Commands::Init, :new, replacement) do
+        assert_equal "initialized #{dir}", command.send(:default_init, dir)
+      end
+
+      assert_equal false, options.fetch(:emit)
+    end
+  end
+
+  def test_default_enroll_uses_non_emitting_programmatic_mode
+    with_tmp_dir do |dir|
+      require "hive/commands/daemon"
+      options = nil
+      fake = Object.new.tap { |object| object.define_singleton_method(:call) { true } }
+      command = Hive::Commands::Setup.new(project_path: dir)
+      registered = [ { "name" => File.basename(dir), "path" => dir } ]
+
+      replacement = lambda do |_subcommand, _target, **kwargs|
+        options = kwargs
+        fake
+      end
+      with_replaced_singleton_method(Hive::Config, :registered_projects, -> { registered }) do
+        with_replaced_singleton_method(Hive::Commands::Daemon, :new, replacement) do
+          assert_equal "enrolled #{File.basename(dir)}", command.send(:default_enroll, dir)
+        end
+      end
+
+      assert_equal false, options.fetch(:emit)
+    end
+  end
+
+  def test_refreshed_bundle_restarts_active_unchanged_service_and_checks_health
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: true, diag: bootstrappable_diag, json: true)
+      restarts = []
+      probes = []
+      installer = FakeInstaller.new(target: "/u/hive-web.service", kind: :unchanged)
+      installer.define_singleton_method(:status_snapshot) { { "service_active" => true } }
+      installer.define_singleton_method(:restart!) { restarts << true; :ok }
+      ctx[:cmd].instance_variable_set(:@web_installer, installer)
+      ctx[:cmd].instance_variable_set(
+        :@web_health_probe,
+        ->(url, timeout:) { probes << [ url, timeout ]; true }
+      )
+
+      ctx[:cmd].call
+      payload = JSON.parse(ctx[:out].string)
+      web_phase = payload["phases"].find { |phase| phase["name"] == "web_install" }
+
+      assert web_phase["ok"], web_phase.inspect
+      assert_equal [ true ], restarts
+      assert_equal 1, probes.length
+      assert_equal "http://127.0.0.1:4567/health", probes.first.first
+      assert_operator probes.first.last, :>, 0
+      assert_match(/restarted=true/, web_phase["detail"])
+    end
+  end
+
+  def test_refreshed_active_service_health_failure_fails_web_phase
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: true, diag: bootstrappable_diag, json: true)
+      installer = FakeInstaller.new(target: "/u/hive-web.service", kind: :unchanged)
+      installer.define_singleton_method(:status_snapshot) { { "service_active" => true } }
+      installer.define_singleton_method(:restart!) { :ok }
+      ctx[:cmd].instance_variable_set(:@web_installer, installer)
+      ctx[:cmd].instance_variable_set(:@web_health_probe, ->(_url, timeout:) { false })
+
+      error = assert_raises(Hive::Error) { ctx[:cmd].call }
+      assert_match(/health check failed/, error.message)
+      payload = JSON.parse(ctx[:out].string)
+      web_phase = payload["phases"].find { |phase| phase["name"] == "web_install" }
+      refute web_phase["ok"]
+      assert_match(%r{127\.0\.0\.1:4567/health}, web_phase["error"])
+    end
+  end
+
+  def test_injected_health_probe_is_bounded_by_setup_deadline
+    command = Hive::Commands::Setup.new(
+      web_health_probe: ->(_url, timeout:) { sleep 1; true },
+      web_health_timeout: 0.01
+    )
+    started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+    refute command.send(:web_service_healthy?)
+    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+    assert_operator elapsed, :<, 0.5
+  end
+
+  def test_bare_setup_prints_foreground_hint
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: false)
+      capture_io { ctx[:cmd].call }
+      assert_match(/hive web/, ctx[:out].string)
+      refute_match(/web UI at http/, ctx[:out].string)
+    end
+  end
+
+  def test_service_setup_prints_url
+    with_tmp_dir do |dir|
+      ctx = build(dir, service: true)
+      capture_io { ctx[:cmd].call }
+      assert_match(%r{http://127\.0\.0\.1:4567}, ctx[:out].string)
+    end
+  end
+
+  def test_shared_binary_path_passed_through
+    with_tmp_dir do |dir|
+      seen = []
+      installer = Object.new
+      installer.define_singleton_method(:install!) { |**| seen << :d; Hive::Commands::ServiceInstaller::Outcome.new(:unchanged) }
+      installer.define_singleton_method(:target_path) { "/d" }
+      installer.define_singleton_method(:messages) { [] }
+      web = Object.new
+      web.define_singleton_method(:install!) { |**| seen << :w; Hive::Commands::ServiceInstaller::Outcome.new(:unchanged) }
+      web.define_singleton_method(:target_path) { "/w" }
+      web.define_singleton_method(:messages) { [] }
+
+      report = healthy_diag
+      probe = Object.new
+      probe.define_singleton_method(:call) { report }
+      cmd = Hive::Commands::Setup.new(
+        project_path: dir,
+        service: true,
+        binary_path: "/exact/hive",
+        diagnostics: probe,
+        daemon_installer: installer,
+        web_installer: web,
+        init_runner: ->(*) { "ok" },
+        qmd_installer: -> { "ok" },
+        app_bundle: Object.new.tap { |o| o.define_singleton_method(:ensure_installed!) { "app" } },
+        output: StringIO.new,
+        err: StringIO.new
+      )
+      capture_io { cmd.call }
+      assert_equal %i[d w], seen
+      assert_equal "/exact/hive", cmd.instance_variable_get(:@binary_path)
+    end
+  end
+end
diff --git a/test/unit/commands/web/service_installer_test.rb b/test/unit/commands/web/service_installer_test.rb
new file mode 100644
index 00000000..c9782ae8
--- /dev/null
+++ b/test/unit/commands/web/service_installer_test.rb
@@ -0,0 +1,208 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/commands/web/service_installer"
+
+class WebServiceInstallerTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_linux_template_contains_resolved_binary_and_web_subcommand
+    with_tmp_dir do |dir|
+      hive = File.join(dir, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(hive))
+      File.write(hive, "#!/bin/sh\n")
+      FileUtils.chmod(0o755, hive)
+
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux-gnu",
+        home: dir,
+        binary_path: hive,
+        systemctl_available: true,
+        runner: ->(_argv) { true }
+      )
+      installer.install!(autostart: false)
+      unit = File.read(File.join(dir, ".config/systemd/user/hive-web.service"))
+      assert_includes unit, "ExecStart=#{hive} web"
+      assert_includes unit, "Restart=on-failure"
+      assert_includes unit, "StartLimitBurst=3"
+      refute_includes unit, "daemon start"
+    end
+  end
+
+  def test_macos_template_contains_resolved_binary_and_web_subcommand
+    with_tmp_dir do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "darwin23",
+        home: dir,
+        binary_path: "/opt/hive/bin/hive",
+        runner: ->(_argv) { true }
+      )
+      installer.install!(autostart: false)
+      plist = File.read(File.join(dir, "Library/LaunchAgents/local.hive-web.plist"))
+      assert_includes plist, "<string>/opt/hive/bin/hive</string>"
+      assert_includes plist, "<string>web</string>"
+      assert_includes plist, "SuccessfulExit"
+      assert_includes plist, "[ -x \"$0\" ] || exit 0"
+      # Exec argv must be `hive web`, not a daemon subcommand.
+      refute_match(%r{<string>daemon</string>}, plist)
+    end
+  end
+
+  def test_install_writes_once_and_noops_when_identical
+    with_tmp_dir do |dir|
+      commands = []
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: "/tmp/hive",
+        systemctl_available: true,
+        runner: ->(argv) { commands << argv; true }
+      )
+      first = installer.install!(autostart: true)
+      assert_equal :written, first.kind
+      second = installer.install!(autostart: true)
+      assert_equal :unchanged, second.kind
+    end
+  end
+
+  def test_install_refuses_drift_without_force_and_backs_up_with_force
+    with_tmp_dir do |dir|
+      path = File.join(dir, ".config/systemd/user/hive-web.service")
+      FileUtils.mkdir_p(File.dirname(path))
+      File.write(path, "custom unit\n")
+
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: "/tmp/hive",
+        systemctl_available: true,
+        runner: ->(_argv) { true }
+      )
+      drifted = installer.install!(autostart: false)
+      assert_equal :drifted, drifted.kind
+      assert_equal "custom unit\n", File.read(path)
+
+      upgraded = installer.install!(autostart: false, force: true)
+      assert_equal :upgraded, upgraded.kind
+      assert upgraded.backup_path
+      assert File.exist?(upgraded.backup_path)
+      assert_includes File.read(path), "ExecStart="
+    end
+  end
+
+  def test_start_stop_status_select_correct_argv
+    with_tmp_dir do |dir|
+      commands = []
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: "/tmp/hive",
+        systemctl_available: true,
+        runner: ->(argv) { commands << argv; true }
+      )
+      installer.install!(autostart: false)
+      commands.clear
+
+      assert_equal :ok, installer.start!
+      assert_includes commands, %w[systemctl --user start hive-web]
+
+      commands.clear
+      assert_equal :ok, installer.stop!
+      assert_includes commands, %w[systemctl --user stop hive-web]
+
+      commands.clear
+      snap = installer.status_snapshot
+      assert_includes commands, %w[systemctl --user is-active hive-web]
+      assert snap["service_installed"]
+    end
+  end
+
+  def test_linux_restart_uses_service_manager
+    with_tmp_dir do |dir|
+      commands = []
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: "/tmp/hive",
+        systemctl_available: true,
+        runner: ->(argv) { commands << argv; true }
+      )
+
+      assert_equal :ok, installer.restart!
+      assert_equal [
+        %w[systemctl --user daemon-reload],
+        %w[systemctl --user restart hive-web]
+      ], commands
+    end
+  end
+
+  def test_macos_start_stop_use_launchctl
+    with_tmp_dir do |dir|
+      commands = []
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "darwin23",
+        home: dir,
+        binary_path: "/opt/hive/bin/hive",
+        runner: ->(argv) { commands << argv; true }
+      )
+      installer.install!(autostart: false)
+      commands.clear
+      path = installer.target_path
+
+      assert_equal :ok, installer.start!
+      assert_equal [ [ "launchctl", "load", path ] ], commands
+
+      commands.clear
+      assert_equal :ok, installer.stop!
+      assert_equal [ [ "launchctl", "unload", path ] ], commands
+    end
+  end
+
+  def test_macos_restart_unloads_then_loads_unit
+    with_tmp_dir do |dir|
+      commands = []
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "darwin23",
+        home: dir,
+        binary_path: "/opt/hive/bin/hive",
+        runner: ->(argv) { commands << argv; true }
+      )
+      installer.install!(autostart: false)
+      commands.clear
+      path = installer.target_path
+
+      assert_equal :ok, installer.restart!
+      assert_equal [ [ "launchctl", "unload", path ], [ "launchctl", "load", path ] ], commands
+    end
+  end
+
+  def test_linux_without_systemd_reports_autostart_unavailable
+    with_tmp_dir do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: "/tmp/hive",
+        systemctl_available: false,
+        runner: ->(_argv) { true }
+      )
+      result = installer.install!(autostart: true)
+      assert_equal :autostart_unavailable, result.kind
+      assert File.exist?(installer.target_path)
+      assert_equal :autostart_unavailable, installer.start!
+    end
+  end
+
+  def test_unsupported_os_reports_clearly
+    with_tmp_dir do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "mingw32",
+        home: dir,
+        binary_path: "/tmp/hive",
+        runner: ->(_argv) { true }
+      )
+      result = installer.install!(autostart: true)
+      assert_equal :unsupported, result.kind
+      assert_equal :unsupported, installer.start!
+    end
+  end
+end
diff --git a/test/unit/commands/web_bind_policy_test.rb b/test/unit/commands/web_bind_policy_test.rb
new file mode 100644
index 00000000..7a822e40
--- /dev/null
+++ b/test/unit/commands/web_bind_policy_test.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/commands/web"
+require "hive/web/loopback"
+
+class WebBindPolicyTest < Minitest::Test
+  include HiveTestHelper
+
+  class ExecCaught < StandardError
+    attr_reader :env, :argv
+
+    def initialize(env, argv)
+      @env = env
+      @argv = argv
+      super("exec")
+    end
+  end
+
+  def test_loopback_classifier
+    %w[127.0.0.1 127.1.2.3 127.255.255.254 ::1 localhost 127.0.0.1:4567 [::1]].each do |addr|
+      assert Hive::Web::Loopback.address?(addr), "#{addr} should be loopback"
+    end
+    %w[0.0.0.0 192.168.1.1 10.0.0.1 8.8.8.8 example.com 126.0.0.1 128.0.0.1].each do |addr|
+      refute Hive::Web::Loopback.address?(addr), "#{addr} should not be loopback"
+    end
+  end
+
+  def test_loopback_bind_allowed_without_owner
+    with_tmp_global_config do
+      cfg = Hive::Config.load_global_web
+      cmd = Hive::Commands::Web.new
+      capture_io { cmd.apply_bind_policy!("127.0.0.1", cfg) }
+      capture_io { cmd.apply_bind_policy!("::1", cfg) }
+      capture_io { cmd.apply_bind_policy!("localhost", cfg) }
+    end
+  end
+
+  def test_non_loopback_without_owner_or_unsafe_refused
+    with_tmp_global_config do
+      cfg = Hive::Config.load_global_web
+      cmd = Hive::Commands::Web.new
+      error = assert_raises(Hive::Error) do
+        capture_io { cmd.apply_bind_policy!("0.0.0.0", cfg) }
+      end
+      assert_match(/refusing non-loopback bind/, error.message)
+      assert_match(/--unsafe/, error.message)
+    end
+  end
+
+  def test_non_loopback_with_owner_warns_and_allows
+    with_tmp_global_config do |home|
+      File.write(File.join(home, "config.yml"), {
+        "web" => { "github" => { "owner" => "alice" } }
+      }.to_yaml)
+      cfg = Hive::Config.load_global_web
+      cmd = Hive::Commands::Web.new
+      _out, err = capture_io { cmd.apply_bind_policy!("0.0.0.0", cfg) }
+      assert_match(/WARNING binding 0.0.0.0/, err)
+      assert_match(/alice/, err)
+    end
+  end
+
+  def test_non_loopback_with_unsafe_warns_and_allows
+    with_tmp_global_config do
+      cfg = Hive::Config.load_global_web
+      cmd = Hive::Commands::Web.new(unsafe: true)
+      _out, err = capture_io { cmd.apply_bind_policy!("0.0.0.0", cfg) }
+      assert_match(/WARNING binding 0.0.0.0/, err)
+      assert_match(/--unsafe/, err)
+    end
+  end
+
+  def test_foreground_sets_loopback_mode_env_only_for_loopback_bind
+    with_tmp_global_config do
+      with_stub_rails_app(prepare_exit: 0) do
+        original = Kernel.method(:exec)
+        caught = nil
+        Kernel.define_singleton_method(:exec) do |env, *argv|
+          raise ExecCaught.new(env, argv)
+        end
+        begin
+          capture_io { Hive::Commands::Web.new(bind: "127.0.0.1").call }
+        rescue ExecCaught => e
+          caught = e
+        ensure
+          Kernel.define_singleton_method(:exec, original)
+        end
+        refute_nil caught
+        assert_equal "1", caught.env[Hive::Web::Loopback::ENV_MODE]
+      end
+    end
+  end
+
+  def test_docker_style_public_bind_does_not_set_loopback_mode
+    with_tmp_global_config do |home|
+      File.write(File.join(home, "config.yml"), {
+        "web" => { "github" => { "owner" => "hivebox-owner" } }
+      }.to_yaml)
+      with_stub_rails_app(prepare_exit: 0) do
+        original = Kernel.method(:exec)
+        caught = nil
+        Kernel.define_singleton_method(:exec) do |env, *argv|
+          raise ExecCaught.new(env, argv)
+        end
+        begin
+          capture_io { Hive::Commands::Web.new(bind: "0.0.0.0").call }
+        rescue ExecCaught => e
+          caught = e
+        ensure
+          Kernel.define_singleton_method(:exec, original)
+        end
+        refute_nil caught
+        refute caught.env.key?(Hive::Web::Loopback::ENV_MODE)
+      end
+    end
+  end
+
+  def test_non_loopback_refusal_happens_before_app_resolve
+    with_tmp_global_config do
+      cmd = Hive::Commands::Web.new(bind: "0.0.0.0")
+      resolved = false
+      cmd.define_singleton_method(:rails_app_dir) do
+        resolved = true
+        "/tmp/should-not-run"
+      end
+      assert_raises(Hive::Error) { capture_io { cmd.call } }
+      refute resolved, "bind policy must fail before rails_app_dir / db work"
+    end
+  end
+
+  # Minimal rails stub shared with web_command_test patterns.
+  def with_stub_rails_app(prepare_exit:)
+    Dir.mktmpdir("hive-webapp") do |dir|
+      FileUtils.mkdir_p(File.join(dir, "config"))
+      File.write(File.join(dir, "config", "application.rb"), "# rails app marker")
+      FileUtils.mkdir_p(File.join(dir, "bin"))
+      File.write(File.join(dir, "bin", "rails"), <<~SH)
+        #!/usr/bin/env bash
+        [ "$1" = "db:prepare" ] && exit #{prepare_exit}
+        exit 0
+      SH
+      FileUtils.chmod(0o755, File.join(dir, "bin", "rails"))
+      with_env("HIVEBOX_WEB_APP_DIR" => dir) { yield dir }
+    end
+  end
+end
diff --git a/test/unit/daemon/child_supervisor_test.rb b/test/unit/daemon/child_supervisor_test.rb
index 1c68f6d9..a92e4422 100644
--- a/test/unit/daemon/child_supervisor_test.rb
+++ b/test/unit/daemon/child_supervisor_test.rb
@@ -79,6 +79,37 @@ class HiveDaemonChildSupervisorTest < Minitest::Test
     end
   end
 
+  def test_maintenance_command_uses_current_binary_resolver_not_stale_daemon_binary
+    with_tmp_dir do |dir|
+      stale_marker = File.join(dir, "stale-ran")
+      current_marker = File.join(dir, "current-ran")
+      stale = File.join(dir, "stale", "hive")
+      current = File.join(dir, "current", "hive")
+      [ [ stale, stale_marker ], [ current, current_marker ] ].each do |path, marker|
+        FileUtils.mkdir_p(File.dirname(path))
+        File.write(path, "#!/bin/sh\ntouch #{Shellwords.escape(marker)}\n")
+        FileUtils.chmod(0o755, path)
+      end
+
+      sup = Hive::Daemon::ChildSupervisor.new(
+        hive_bin: stale,
+        maintenance_binary_resolver: -> { current },
+        log_dir_for_task: ->(project, slug) { File.join(dir, project, slug, "child.log") }
+      )
+      sup.spawn(
+        command_string: "hive daemon install --force",
+        project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT,
+        slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG,
+        stage: nil
+      )
+      completed = wait_for_completion(sup, max_attempts: 50)
+
+      assert_equal 1, completed.size
+      assert File.exist?(current_marker), "repair must execute through the current install binary"
+      refute File.exist?(stale_marker), "the daemon's stale HIVE_BIN must not repair itself"
+    end
+  end
+
   def test_spawn_tmpdir_log_fallback_loads_its_own_dependency
     script = <<~RUBY
       require "hive/daemon/child_supervisor"
diff --git a/test/unit/daemon/dispatch_request_queue_test.rb b/test/unit/daemon/dispatch_request_queue_test.rb
index a3c6f5e7..8b356109 100644
--- a/test/unit/daemon/dispatch_request_queue_test.rb
+++ b/test/unit/daemon/dispatch_request_queue_test.rb
@@ -327,6 +327,32 @@ class HiveDaemonDispatchRequestQueueTest < Minitest::Test
     refute Q.valid_argv?([ "hive", :run, "slug" ])
   end
 
+  def test_maintenance_argv_requires_exact_reserved_context
+    argv = Q::MAINTENANCE_ARGV
+
+    assert Q.valid_argv?(
+      argv,
+      project: Q::GLOBAL_PROJECT,
+      slug: Q::GLOBAL_SLUG
+    )
+    refute Q.valid_argv?(argv), "context-free validation must reject host maintenance"
+    refute Q.valid_argv?(argv, project: "real-project", slug: "real-task")
+    refute Q.valid_argv?(argv, project: Q::GLOBAL_PROJECT, slug: "real-task")
+  end
+
+  def test_write_request_rejects_maintenance_argv_for_real_project
+    Dir.mktmpdir("hive-dispatch-queue") do |dir|
+      assert_raises(ArgumentError) do
+        Q.write_request!(
+          project: "real-project",
+          slug: "real-task",
+          argv: Q::MAINTENANCE_ARGV,
+          state_home: dir
+        )
+      end
+    end
+  end
+
   def test_filename_for_is_chronologically_sortable
     a = Q.filename_for(created_at: Time.utc(2026, 5, 28, 18, 11, 44), request_id: "A")
     b = Q.filename_for(created_at: Time.utc(2026, 5, 28, 18, 13, 9), request_id: "B")
diff --git a/test/unit/daemon/dispatcher_test.rb b/test/unit/daemon/dispatcher_test.rb
index 50667c8b..d735bad4 100644
--- a/test/unit/daemon/dispatcher_test.rb
+++ b/test/unit/daemon/dispatcher_test.rb
@@ -3158,6 +3158,30 @@ end
     end
   end
 
+  def test_dispatch_request_rejects_maintenance_argv_for_real_project
+    Dir.mktmpdir("hive-dispatch-queue") do |state_home|
+      dispatcher, sup, _ctrl, logger, _mw = make_dispatcher(
+        rows: [], dispatch_request_state_home: state_home
+      )
+      write_request_file(
+        state_home,
+        slug: "s1",
+        request_id: "BADMAINT",
+        project: "p1",
+        argv: Q::MAINTENANCE_ARGV
+      )
+
+      dispatcher.tick(now: T0)
+
+      assert_empty sup.spawned
+      rejected = logger.events.find do |(name, attrs)|
+        name == :dispatch_request_rejected && attrs[:request_id] == "BADMAINT"
+      end
+      refute_nil rejected
+      assert_equal "invalid_argv", rejected[1][:reason]
+    end
+  end
+
   def test_dispatch_request_rejected_when_project_unknown
     Dir.mktmpdir("hive-dispatch-queue") do |state_home|
       dispatcher, sup, _ctrl, logger, _mw = make_dispatcher(
diff --git a/test/unit/daemon/status_report_test.rb b/test/unit/daemon/status_report_test.rb
new file mode 100644
index 00000000..103831a8
--- /dev/null
+++ b/test/unit/daemon/status_report_test.rb
@@ -0,0 +1,366 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/daemon/status_report"
+require "hive/commands/daemon/service_installer"
+
+class DaemonStatusReportTest < Minitest::Test
+  include HiveTestHelper
+
+  FakeInstaller = Struct.new(
+    :service_state_hash, :expected, :installed, :parse_error,
+    keyword_init: true
+  ) do
+    def service_state = service_state_hash
+    def resolved_binary_for_status = expected
+    def installed_binary_path = installed
+    def binary_parse_error = parse_error
+  end
+
+  def report_for(dir, installer:, version_probe: ->(_b) { Hive::VERSION })
+    Hive::Daemon::StatusReport.new(
+      hive_home: dir,
+      installer: installer,
+      version_probe: version_probe,
+      update_state: nil
+    )
+  end
+
+  def make_bin(dir, name = "hive")
+    path = File.join(dir, "bin", name)
+    FileUtils.mkdir_p(File.dirname(path))
+    File.write(path, "#!/bin/sh\n")
+    FileUtils.chmod(0o755, path)
+    path
+  end
+
+  def test_matching_path_and_version_reports_none
+    with_tmp_dir do |dir|
+      hive = make_bin(dir)
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u/hive-daemon.service",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: hive,
+        installed: hive,
+        parse_error: nil
+      )
+      payload = report_for(dir, installer: installer).to_h
+      assert_equal "none", payload["binary_drift"]
+      assert_equal hive, payload["expected_binary"]
+      assert_equal hive, payload["installed_binary"]
+      assert_equal Hive::VERSION, payload["installed_version"]
+      assert_equal Hive::VERSION, payload["current_version"]
+    end
+  end
+
+  def test_different_path_reports_path_drift
+    with_tmp_dir do |dir|
+      expected = make_bin(dir, "current-hive")
+      installed = make_bin(dir, "stale-hive")
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u/hive-daemon.service",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: expected,
+        installed: installed,
+        parse_error: nil
+      )
+      payload = report_for(dir, installer: installer).to_h
+      assert_equal "path", payload["binary_drift"]
+    end
+  end
+
+  def test_same_path_different_version_reports_version_drift
+    with_tmp_dir do |dir|
+      hive = make_bin(dir)
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u/hive-daemon.service",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: hive,
+        installed: hive,
+        parse_error: nil
+      )
+      payload = report_for(
+        dir,
+        installer: installer,
+        version_probe: ->(_b) { "0.0.1" }
+      ).to_h
+      assert_equal "version", payload["binary_drift"]
+      assert_equal "0.0.1", payload["installed_version"]
+    end
+  end
+
+  def test_unparseable_unit_reports_unparseable
+    with_tmp_dir do |dir|
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u/hive-daemon.service",
+          "service_installed" => true,
+          "service_enabled" => false
+        },
+        expected: "/opt/hive",
+        installed: nil,
+        parse_error: "no ExecStart= line"
+      )
+      payload = report_for(dir, installer: installer).to_h
+      assert_equal "unparseable", payload["binary_drift"]
+    end
+  end
+
+  def test_missing_unit_reports_not_applicable
+    with_tmp_dir do |dir|
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u/hive-daemon.service",
+          "service_installed" => false,
+          "service_enabled" => false
+        },
+        expected: "/opt/hive",
+        installed: nil,
+        parse_error: nil
+      )
+      payload = report_for(dir, installer: installer).to_h
+      assert_equal "not_applicable", payload["binary_drift"]
+    end
+  end
+
+  def test_probe_failure_reports_unreadable
+    with_tmp_dir do |dir|
+      bad = Object.new
+      def bad.service_state
+        raise "boom"
+      end
+      payload = report_for(dir, installer: bad).to_h
+      assert_equal "unreadable", payload["binary_drift"]
+      assert_nil payload["service_installed"]
+    end
+  end
+
+  def test_systemd_and_launchd_binary_parsing
+    with_tmp_dir do |dir|
+      hive = File.join(dir, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(hive))
+      File.write(hive, "#!/bin/sh\n")
+      FileUtils.chmod(0o755, hive)
+
+      linux = Hive::Commands::Daemon::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: hive,
+        systemctl_available: false,
+        runner: ->(*) { true }
+      )
+      linux.install!(autostart: false)
+      assert_equal hive, linux.installed_binary_path
+
+      # Escaped path with spaces
+      spaced = File.join(dir, "my hive", "hive")
+      FileUtils.mkdir_p(File.dirname(spaced))
+      File.write(spaced, "#!/bin/sh\n")
+      FileUtils.chmod(0o755, spaced)
+      linux2 = Hive::Commands::Daemon::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: spaced,
+        systemctl_available: false,
+        runner: ->(*) { true }
+      )
+      linux2.install!(autostart: false, force: true)
+      assert_equal spaced, linux2.installed_binary_path
+
+      mac = Hive::Commands::Daemon::ServiceInstaller.new(
+        host_os: "darwin",
+        home: dir,
+        binary_path: hive,
+        runner: ->(*) { true }
+      )
+      mac.install!(autostart: false)
+      assert_equal hive, mac.installed_binary_path
+    end
+  end
+
+  def test_version_probe_timeout_does_not_hang
+    with_tmp_dir do |dir|
+      hive = make_bin(dir)
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: hive,
+        installed: hive,
+        parse_error: nil
+      )
+      hung = lambda do |_binary|
+        raise Timeout::Error
+      end
+      payload = report_for(dir, installer: installer, version_probe: hung).to_h
+      assert_equal "unreadable", payload["binary_drift"]
+      assert_nil payload["installed_version"]
+    end
+  end
+
+  def test_default_version_probe_is_bounded_and_reports_timeout_as_unreadable
+    with_tmp_dir do |dir|
+      hive = File.join(dir, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(hive))
+      File.write(hive, "#!/bin/sh\nsleep 30\n")
+      FileUtils.chmod(0o755, hive)
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: hive,
+        installed: hive,
+        parse_error: nil
+      )
+      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+      payload = Hive::Daemon::StatusReport.new(
+        hive_home: dir,
+        installer: installer,
+        update_state: nil
+      ).to_h
+
+      elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+      assert_operator elapsed, :<, 5
+      assert_equal "unreadable", payload["binary_drift"]
+      assert_nil payload["installed_version"]
+    end
+  end
+
+  def test_default_version_probe_reports_nonzero_and_malformed_output_as_unreadable
+    with_tmp_dir do |dir|
+      scripts = {
+        "nonzero-hive" => "#!/bin/sh\necho 'hive 9.9.9'\nexit 1\n",
+        "malformed-hive" => "#!/bin/sh\necho 'unknown version'\n"
+      }
+      scripts.each do |name, body|
+        hive = File.join(dir, "bin", name)
+        FileUtils.mkdir_p(File.dirname(hive))
+        File.write(hive, body)
+        FileUtils.chmod(0o755, hive)
+        installer = FakeInstaller.new(
+          service_state_hash: {
+            "platform" => "linux",
+            "unit_path" => "/u",
+            "service_installed" => true,
+            "service_enabled" => true
+          },
+          expected: hive,
+          installed: hive,
+          parse_error: nil
+        )
+
+        payload = Hive::Daemon::StatusReport.new(
+          hive_home: dir,
+          installer: installer,
+          update_state: nil
+        ).to_h
+
+        assert_equal "unreadable", payload["binary_drift"], name
+        assert_nil payload["installed_version"], name
+      end
+    end
+  end
+
+  def test_nil_or_malformed_version_probe_result_reports_unreadable
+    with_tmp_dir do |dir|
+      hive = make_bin(dir)
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: hive,
+        installed: hive,
+        parse_error: nil
+      )
+
+      [ nil, "not-a-version" ].each do |probe_result|
+        payload = report_for(
+          dir,
+          installer: installer,
+          version_probe: ->(_binary) { probe_result }
+        ).to_h
+        assert_equal "unreadable", payload["binary_drift"]
+        assert_nil payload["installed_version"]
+      end
+    end
+  end
+
+  def test_reused_pid_is_not_reported_as_running
+    with_tmp_dir do |dir|
+      hive = make_bin(dir)
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => "/u",
+          "service_installed" => true,
+          "service_enabled" => true
+        },
+        expected: hive,
+        installed: hive,
+        parse_error: nil
+      )
+      File.write(
+        File.join(dir, ".daemon.pid"),
+        {
+          "pid" => Process.pid,
+          "process_start_time" => "definitely-not-this-process",
+          "started_at" => Time.now.utc.iso8601
+        }.to_yaml
+      )
+
+      payload = report_for(dir, installer: installer).to_h
+
+      assert_equal false, payload["running"]
+      assert_nil payload["pid"]
+    end
+  end
+
+  def test_schema_fields_present
+    with_tmp_dir do |dir|
+      installer = FakeInstaller.new(
+        service_state_hash: {
+          "platform" => "linux",
+          "unit_path" => nil,
+          "service_installed" => false,
+          "service_enabled" => false
+        },
+        expected: "/opt/hive",
+        installed: nil,
+        parse_error: nil
+      )
+      payload = report_for(dir, installer: installer).to_h
+      %w[
+        schema schema_version ok running pid uptime_sec pid_file log_file
+        service_installed service_enabled unit_path current_version update_nudge
+        expected_binary installed_binary installed_version binary_drift
+      ].each do |key|
+        assert payload.key?(key), "missing #{key}"
+      end
+      assert_includes Hive::Daemon::StatusReport::DRIFT_VALUES, payload["binary_drift"]
+    end
+  end
+end
diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb
index a4ddfc6e..fe843721 100644
--- a/test/unit/gemspec_test.rb
+++ b/test/unit/gemspec_test.rb
@@ -25,9 +25,10 @@ class GemspecTest < Minitest::Test
     assert_includes spec.files, "bin/hv"
   end
 
-  # The web tier is a Rails app under web/, supported only in the Docker
-  # image or a source checkout — the gem must stay a lean CLI and not
-  # package the app or its old Sinatra-era assets.
+  # The web tier is a Rails app under web/. Local installs acquire a
+  # version-matched release archive (hive-web-<version>.tar.gz) via
+  # Hive::Web::AppBundle; the gem must stay a lean CLI and not package
+  # the app or its old Sinatra-era assets.
   def test_gem_package_excludes_the_rails_web_app
     spec = Gem::Specification.load(GEMSPEC_PATH)
 
@@ -36,4 +37,14 @@ class GemspecTest < Minitest::Test
     refute spec.files.any? { |f| f.start_with?("public/") },
            "no Sinatra-era static assets should be packaged"
   end
+
+  def test_web_release_archive_name_matches_app_bundle_contract
+    require "hive/web/app_bundle"
+    bundle = Hive::Web::AppBundle.new(version: Hive::VERSION)
+    assert_equal "hive-web-#{Hive::VERSION}.tar.gz", bundle.archive_basename
+    assert_match(
+      %r{\Ahttps://github.com/#{Regexp.escape(Hive::REPO_OWNER)}/#{Regexp.escape(Hive::REPO_NAME)}/releases/download/v#{Regexp.escape(Hive::VERSION)}/hive-web-#{Regexp.escape(Hive::VERSION)}\.tar\.gz\z},
+      bundle.release_asset_url
+    )
+  end
 end
diff --git a/test/unit/openclaw_skills_test.rb b/test/unit/openclaw_skills_test.rb
index eb700e97..37a0b21f 100644
--- a/test/unit/openclaw_skills_test.rb
+++ b/test/unit/openclaw_skills_test.rb
@@ -56,12 +56,28 @@ class OpenClawSkillsTest < Minitest::Test
     assert_includes body, "yay -S --noconfirm --needed hive-bin"
     assert_includes body, "v0.2.0/install.sh"
     assert_includes body, "daemon install"
-    assert_includes body, "init . --json </dev/null"
+    assert_includes body, '"${hive_cmd}" setup --json'
     assert_includes body, "slash-command text after `/hive` as arguments for `hive_cmd`"
     assert_includes body, '"${hive_cmd}" --help'
     assert_includes body, "Pass arguments safely"
   end
 
+  def test_guided_setup_delegates_provisioning_to_hive_setup
+    _metadata, body = read_skill("hive")
+    guided_setup = section(body, "## Guided Setup")
+
+    assert_includes guided_setup, '"${hive_cmd}" setup --json'
+    assert_includes guided_setup, "--service"
+    assert_includes guided_setup, "--no-init"
+    assert_includes guided_setup, "diagnostics"
+    assert_includes guided_setup, "qmd"
+    assert_includes guided_setup, "web bundle"
+    assert_includes guided_setup, "enrollment"
+    assert_includes guided_setup, "phases"
+    refute_includes guided_setup, '"${hive_cmd}" daemon install'
+    refute_includes guided_setup, '"${hive_cmd}" init . --json'
+  end
+
   def test_umbrella_skill_guards_destructive_and_blocking_commands
     _metadata, body = read_skill("hive")
 
diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb
index 2d10237d..a5bc040c 100644
--- a/test/unit/schema_files_test.rb
+++ b/test/unit/schema_files_test.rb
@@ -12,10 +12,15 @@ require "hive/commands/prune"
 require "hive/commands/run"
 require "hive/commands/stage_action"
 require "hive/commands/status"
+require "hive/commands/setup"
+require "hive/commands/web"
+require "hive/commands/service_installer/outcome"
+require "hive/setup/diagnostics"
 require "hive/tui/snapshot"
 require "hive/daemon/dispatch_request_queue"
 require "hive/daemon/dispatch_result_queue"
 require "tmpdir"
+require "stringio"
 
 # Schema files under schemas/ are the published artefact for external
 # consumers (non-Ruby SDKs, CI validators, etc.). They must:
@@ -1476,6 +1481,7 @@ class SchemaFilesTest < Minitest::Test
     producer_required = %w[
       schema schema_version ok running pid uptime_sec pid_file log_file
       service_installed service_enabled unit_path current_version update_nudge
+      expected_binary installed_binary installed_version binary_drift
     ].sort
     assert_equal producer_required, schema_required,
                  "schema/producer required-key drift in hive-daemon-status.v1.json"
@@ -2063,6 +2069,83 @@ class SchemaFilesTest < Minitest::Test
            "schema must reject error_kind values outside the closed enum"
   end
 
+  # ── local setup / web lifecycle contracts ───────────────────────────────
+
+  def test_hive_setup_success_payload_validates_against_published_schema
+    result = Hive::Setup::Diagnostics::Result.new(
+      name: "ruby", status: "ok", detail: "ruby ok", fix: nil,
+      bootstrappable: false, path: "/usr/bin/ruby", version: "3.4.0"
+    )
+    report = Hive::Setup::Diagnostics::Report.new(
+      results: [ result ], platform: "linux", ok: true
+    )
+    diagnostics = Object.new
+    diagnostics.define_singleton_method(:call) { report }
+    output = StringIO.new
+
+    Dir.mktmpdir("hive-setup-schema") do |dir|
+      Hive::Commands::Setup.new(
+        project_path: dir, no_bootstrap: true, json: true,
+        binary_path: "/opt/hive/bin/hive", diagnostics: diagnostics,
+        output: output, err: StringIO.new
+      ).call
+    end
+
+    payload = JSON.parse(output.string)
+    schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-setup"))))
+    assert_empty schemer.validate(payload).to_a
+  end
+
+  def test_hive_web_status_active_and_inactive_payloads_validate
+    schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-status"))))
+
+    [ true, false ].each do |active|
+      installer = Object.new
+      installer.define_singleton_method(:status_snapshot) do
+        {
+          "platform" => "linux", "service_installed" => true,
+          "service_enabled" => true, "service_active" => active,
+          "unit_path" => "/tmp/hive-web.service"
+        }
+      end
+      command = Hive::Commands::Web.new(subcommand: "status", json: true)
+      command.define_singleton_method(:service_installer) { installer }
+      output, = capture_io do
+        begin
+          command.call
+        rescue Hive::Error
+          nil
+        end
+      end
+      payload = JSON.parse(output)
+      assert_empty schemer.validate(payload).to_a, "invalid active=#{active} payload: #{payload.inspect}"
+    end
+  end
+
+  def test_hive_web_install_success_and_failure_payloads_validate
+    schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-install"))))
+    installer = Struct.new(:messages) do
+      def envelope_platform = "linux"
+      def target_path = "/tmp/hive-web.service"
+    end.new([])
+
+    success = Hive::Commands::ServiceInstaller::Outcome.new(:written)
+    command = Hive::Commands::Web.new(json: true)
+    output, = capture_io { command.send(:emit_install_outcome, installer, success) }
+    assert_empty schemer.validate(JSON.parse(output)).to_a
+
+    installer.messages << "unit drifted"
+    failure = Hive::Commands::ServiceInstaller::Outcome.new(:drifted)
+    output, = capture_io do
+      begin
+        command.send(:emit_install_outcome, installer, failure)
+      rescue Hive::Error
+        nil
+      end
+    end
+    assert_empty schemer.validate(JSON.parse(output)).to_a
+  end
+
   # ── schema metadata identity ─────────────────────────────────────────────
 
   # Copy-pasting vN.json to vN+1.json and only changing the version const is
diff --git a/test/unit/setup/diagnostics_test.rb b/test/unit/setup/diagnostics_test.rb
new file mode 100644
index 00000000..36e23823
--- /dev/null
+++ b/test/unit/setup/diagnostics_test.rb
@@ -0,0 +1,448 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/setup/diagnostics"
+require "hive/web/app_bundle"
+require "rbconfig"
+
+class SetupDiagnosticsTest < Minitest::Test
+  include HiveTestHelper
+
+  def healthy_bins(dir)
+    FileUtils.mkdir_p(dir)
+    %w[git tmux gh claude codex node npm qmd sqlite3].each do |name|
+      path = File.join(dir, name)
+      File.write(path, "#!/bin/sh\nexit 0\n")
+      FileUtils.chmod(0o755, path)
+    end
+    dir
+  end
+
+  def build(dir, **opts)
+    bins = opts.delete(:bins) || healthy_bins(File.join(dir, "bin"))
+    which = opts.delete(:which) || ->(name) {
+      path = File.join(bins, name)
+      File.executable?(path) ? path : nil
+    }
+    versions = opts.delete(:versions) || {
+      "tmux" => "tmux 3.3a\n",
+      "gh" => "gh version 2.40.0 (2024-01-01)\n",
+      "claude" => "2.1.118 (Claude Code)\n",
+      "codex" => "codex-cli 0.1.0\n",
+      "node" => "v20.11.0\n",
+      "npm" => "10.2.4\n",
+      "git" => "git version 2.43.0\n",
+      "qmd" => "0.5.0\n",
+      "sqlite3" => "3.45.0 2024-01-01\n"
+    }
+    runner = opts.delete(:runner) || ->(argv) {
+      bin = File.basename(argv[0].to_s)
+      if argv[1] == "auth" && argv[2] == "status"
+        [ "Logged in to github.com as someone\n", "", true ]
+      else
+        [ versions[bin].to_s, "", true ]
+      end
+    }
+    agent_auth = opts.delete(:agent_auth) || ->(_name) { true }
+    gh_auth = opts.delete(:gh_auth) || -> { true }
+    app_bundle = opts.delete(:app_bundle) || Hive::Web::AppBundle.new(
+      data_home: File.join(dir, "data"),
+      env: {},
+      source_app_dir: File.join(dir, "no-source"),
+      downloader: ->(*) { raise "no net" },
+      bundler: ->(*) { raise "no bundle" }
+    )
+    # Pre-install a current managed web bundle so default health is green.
+    # The one missing-bundle case runs inside this source checkout, so disable
+    # that production fallback explicitly to exercise the bootstrappable row.
+    if opts.delete(:skip_web_seed)
+      app_bundle.define_singleton_method(:source_checkout_app_dir) { nil }
+    else
+      FileUtils.mkdir_p(File.join(app_bundle.managed_app_dir, "config"))
+      File.write(File.join(app_bundle.managed_app_dir, "config", "application.rb"), "# rails\n")
+      File.write(app_bundle.version_stamp_path, "#{Hive::VERSION}\n")
+    end
+
+    Hive::Setup::Diagnostics.new(
+      env: opts.delete(:env) || { "HOME" => dir, "PATH" => bins },
+      host_os: opts.delete(:host_os) || "linux-gnu",
+      home: dir,
+      which: which,
+      runner: runner,
+      app_bundle: app_bundle,
+      agent_auth: agent_auth,
+      gh_auth: gh_auth,
+      **opts
+    )
+  end
+
+  def test_every_healthy_dependency_is_ok
+    with_tmp_dir do |dir|
+      report = build(dir).call
+      assert report.ok, report.results.map { |r| [ r.name, r.status, r.detail ] }.inspect
+      report.results.each do |row|
+        assert_equal "ok", row.status, "#{row.name} should be ok: #{row.detail}"
+        assert_nil row.fix
+        refute row.bootstrappable
+      end
+    end
+  end
+
+  def test_missing_binary_yields_linux_remediation_without_execution
+    with_tmp_dir do |dir|
+      bins = File.join(dir, "bin")
+      FileUtils.mkdir_p(bins)
+      # Only provide npm so other missing tools are diagnosed; leave git out.
+      %w[tmux gh claude codex node npm qmd].each do |name|
+        path = File.join(bins, name)
+        File.write(path, "#!/bin/sh\n")
+        FileUtils.chmod(0o755, path)
+      end
+      executed = []
+      report = build(
+        dir,
+        bins: bins,
+        runner: ->(argv) {
+          executed << argv
+          [ "tmux 3.3a\n", "", true ]
+        }
+      ).call
+
+      git = report.results.find { |r| r.name == "git" }
+      assert_equal "missing", git.status
+      assert_match(/apt-get install -y git|package manager/, git.fix)
+      refute git.bootstrappable
+      refute report.ok
+      # Diagnostics must not run install commands — only version/auth probes.
+      refute executed.any? { |argv| argv.join(" ").include?("apt-get") }
+    end
+  end
+
+  def test_missing_binary_macos_remediation
+    with_tmp_dir do |dir|
+      report = build(
+        dir,
+        host_os: "darwin23",
+        which: ->(name) { name == "git" ? nil : File.join(dir, "bin", name) },
+        bins: healthy_bins(File.join(dir, "bin"))
+      ).call
+      # Force git missing via which override while keeping path existence for others
+      report = Hive::Setup::Diagnostics.new(
+        env: { "HOME" => dir },
+        host_os: "darwin23",
+        home: dir,
+        which: ->(name) { name == "git" ? nil : "/usr/bin/#{name}" },
+        runner: ->(argv) {
+          bin = File.basename(argv[0])
+          if argv.include?("auth")
+            [ "Logged in\n", "", true ]
+          else
+            [ "#{bin} 3.3.0\n", "", true ]
+          end
+        },
+        agent_auth: ->(*) { true },
+        gh_auth: -> { true },
+        app_bundle: Hive::Web::AppBundle.new(
+          data_home: File.join(dir, "data"),
+          env: {},
+          source_app_dir: File.join(dir, "nope"),
+          downloader: ->(*) { },
+          bundler: ->(*) { }
+        ).tap { |b|
+          FileUtils.mkdir_p(File.join(b.managed_app_dir, "config"))
+          File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n")
+          File.write(b.version_stamp_path, "#{Hive::VERSION}\n")
+        }
+      ).call
+
+      git = report.results.find { |r| r.name == "git" }
+      assert_equal "missing", git.status
+      assert_equal "brew install git", git.fix
+    end
+  end
+
+  def test_unauthenticated_external_clis_emit_exact_login_and_are_not_bootstrappable
+    with_tmp_dir do |dir|
+      report = build(
+        dir,
+        agent_auth: ->(name) { false },
+        gh_auth: -> { false }
+      ).call
+
+      %w[gh claude codex].each do |name|
+        row = report.results.find { |r| r.name == name }
+        assert_equal "unauthenticated", row.status, name
+        refute row.bootstrappable, name
+      end
+      assert_equal "gh auth login", report.results.find { |r| r.name == "gh" }.fix
+      assert_equal "claude setup-token", report.results.find { |r| r.name == "claude" }.fix
+      assert_equal "codex login", report.results.find { |r| r.name == "codex" }.fix
+      refute report.ok
+    end
+  end
+
+  def test_api_key_authentication_avoids_false_negative
+    with_tmp_dir do |dir|
+      # Use real AgentProfiles.logged_in? path with no credential files, but
+      # ANTHROPIC_API_KEY present so claude is considered authenticated.
+      env = {
+        "HOME" => dir,
+        "PATH" => healthy_bins(File.join(dir, "bin")),
+        "ANTHROPIC_API_KEY" => "sk-test",
+        "OPENAI_API_KEY" => "sk-openai"
+      }
+      diag = Hive::Setup::Diagnostics.new(
+        env: env,
+        host_os: "linux",
+        home: dir,
+        which: ->(name) {
+          path = File.join(env["PATH"], name)
+          File.executable?(path) ? path : nil
+        },
+        runner: ->(argv) {
+          if argv[1] == "auth"
+            [ "", "not logged in", false ]
+          else
+            [ "1.0.0\n", "", true ]
+          end
+        },
+        gh_auth: -> { true },
+        app_bundle: Hive::Web::AppBundle.new(
+          data_home: File.join(dir, "data"),
+          env: {},
+          source_app_dir: File.join(dir, "nope"),
+          downloader: ->(*) { },
+          bundler: ->(*) { }
+        ).tap { |b|
+          FileUtils.mkdir_p(File.join(b.managed_app_dir, "config"))
+          File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n")
+          File.write(b.version_stamp_path, "#{Hive::VERSION}\n")
+        }
+      )
+      report = diag.call
+      assert_equal "ok", report.results.find { |r| r.name == "claude" }.status
+      assert_equal "ok", report.results.find { |r| r.name == "codex" }.status
+    end
+  end
+
+  def test_on_disk_agent_credentials_count_as_logged_in
+    with_tmp_dir do |dir|
+      FileUtils.mkdir_p(File.join(dir, ".claude"))
+      File.write(File.join(dir, ".claude", ".credentials.json"), '{"token":"x"}')
+      FileUtils.mkdir_p(File.join(dir, ".codex"))
+      File.write(File.join(dir, ".codex", "auth.json"), '{"token":"y"}')
+
+      diag = Hive::Setup::Diagnostics.new(
+        env: { "HOME" => dir, "PATH" => healthy_bins(File.join(dir, "bin")) },
+        host_os: "linux",
+        home: dir,
+        which: ->(name) {
+          path = File.join(dir, "bin", name)
+          File.executable?(path) ? path : nil
+        },
+        runner: ->(*) { [ "1.0.0\n", "", true ] },
+        gh_auth: -> { true },
+        app_bundle: Hive::Web::AppBundle.new(
+          data_home: File.join(dir, "data"),
+          env: {},
+          source_app_dir: File.join(dir, "nope"),
+          downloader: ->(*) { },
+          bundler: ->(*) { }
+        ).tap { |b|
+          FileUtils.mkdir_p(File.join(b.managed_app_dir, "config"))
+          File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n")
+          File.write(b.version_stamp_path, "#{Hive::VERSION}\n")
+        }
+      )
+      report = diag.call
+      assert_equal "ok", report.results.find { |r| r.name == "claude" }.status
+      assert_equal "ok", report.results.find { |r| r.name == "codex" }.status
+    end
+  end
+
+  def test_qmd_on_path_or_managed_prefix_accepted_and_absent_is_bootstrappable
+    with_tmp_dir do |dir|
+      # Managed prefix without PATH entry.
+      managed = File.join(Hive::Paths.data_home, "qmd", "bin")
+      # Use explicit data_home isolation via HIVE_HOME collapse? Paths uses XDG.
+      # Inject which that misses qmd, but file exists under data_home/qmd.
+      data = File.join(dir, "data", "hive")
+      FileUtils.mkdir_p(File.join(data, "qmd", "bin"))
+      qmd = File.join(data, "qmd", "bin", "qmd")
+      File.write(qmd, "#!/bin/sh\necho 1.0.0\n")
+      FileUtils.chmod(0o755, qmd)
+
+      with_env(
+        "HOME" => dir,
+        "HIVE_HOME" => nil,
+        "XDG_DATA_HOME" => File.join(dir, "data"),
+        "XDG_CONFIG_HOME" => File.join(dir, "config"),
+        "XDG_STATE_HOME" => File.join(dir, "state"),
+        "XDG_CACHE_HOME" => File.join(dir, "cache")
+      ) do
+        bins = healthy_bins(File.join(dir, "bin"))
+        FileUtils.rm_f(File.join(bins, "qmd"))
+        report = Hive::Setup::Diagnostics.new(
+          env: ENV.to_h.merge("HOME" => dir),
+          host_os: "linux",
+          home: dir,
+          which: ->(name) {
+            return nil if name == "qmd"
+
+            path = File.join(bins, name)
+            File.executable?(path) ? path : nil
+          },
+          runner: ->(*) { [ "1.0.0\n", "", true ] },
+          agent_auth: ->(*) { true },
+          gh_auth: -> { true },
+          app_bundle: Hive::Web::AppBundle.new(
+            data_home: File.join(dir, "data", "hive"),
+            env: {},
+            source_app_dir: File.join(dir, "nope"),
+            downloader: ->(*) { },
+            bundler: ->(*) { }
+          ).tap { |b|
+            FileUtils.mkdir_p(File.join(b.managed_app_dir, "config"))
+            File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n")
+            File.write(b.version_stamp_path, "#{Hive::VERSION}\n")
+          }
+        ).call
+        qmd_row = report.results.find { |r| r.name == "qmd" }
+        assert_equal "ok", qmd_row.status, qmd_row.detail
+        assert_equal qmd, qmd_row.path
+      end
+    end
+
+    with_tmp_dir do |dir|
+      bins = healthy_bins(File.join(dir, "bin"))
+      FileUtils.rm_f(File.join(bins, "qmd"))
+      report = build(dir, bins: bins, skip_web_seed: true).call
+      qmd_row = report.results.find { |r| r.name == "qmd" }
+      assert_equal "bootstrappable", qmd_row.status
+      assert qmd_row.bootstrappable
+      assert_match(/npm install --global --prefix/, qmd_row.fix)
+
+      web = report.results.find { |r| r.name == "web_bundle" }
+      assert_equal "bootstrappable", web.status
+      assert web.bootstrappable
+      # qmd + web bootstrappable alone do not fail the report when other deps ok
+      assert report.ok, report.hard_failures.map(&:to_h).inspect
+    end
+  end
+
+  def test_outdated_tmux_is_reported
+    with_tmp_dir do |dir|
+      report = build(
+        dir,
+        versions: {
+          "tmux" => "tmux 2.9\n",
+          "gh" => "gh version 2.40.0\n",
+          "claude" => "2.1.118\n",
+          "codex" => "0.1.0\n",
+          "node" => "v20.0.0\n",
+          "npm" => "10.0.0\n",
+          "git" => "git version 2.40.0\n",
+          "qmd" => "0.5.0\n"
+        }
+      ).call
+      tmux = report.results.find { |r| r.name == "tmux" }
+      assert_equal "outdated", tmux.status
+      assert_match(/below minimum/, tmux.detail)
+      refute report.ok
+    end
+  end
+
+  def test_timeout_and_spawn_errors_degrade_without_hang
+    with_tmp_dir do |dir|
+      report = build(
+        dir,
+        runner: ->(_argv) { raise Timeout::Error, "timed out" }
+      ).call
+      # Version probes fail soft; binaries still found so status stays ok with nil version
+      # or error for parse failures. gh auth will fail → unauthenticated.
+      gh = report.results.find { |r| r.name == "gh" }
+      assert_includes %w[unauthenticated error ok], gh.status
+      # Must return a complete matrix either way.
+      assert_equal 11, report.results.length
+    end
+  end
+
+  def test_default_probe_timeout_kills_descendants_before_returning
+    with_tmp_dir do |dir|
+      child_pid_path = File.join(dir, "child.pid")
+      script = <<~RUBY
+        child = spawn(#{RbConfig.ruby.inspect}, "-e", "sleep 30")
+        File.write(#{child_pid_path.inspect}, child)
+        sleep 30
+      RUBY
+      diagnostics = Hive::Setup::Diagnostics.new(
+        env: { "HOME" => dir, "PATH" => ENV.fetch("PATH", "") },
+        probe_timeout: 1.0
+      )
+
+      out, err, ok = diagnostics.send(:default_runner, [ RbConfig.ruby, "-e", script ])
+
+      assert_equal "", out
+      assert_equal "", err
+      refute ok
+      child_pid = Integer(File.read(child_pid_path))
+      refute process_alive?(child_pid), "probe timeout must not leave a prerequisite child running"
+    end
+  end
+
+  def test_invalid_result_status_raises
+    assert_raises(ArgumentError) do
+      Hive::Setup::Diagnostics::Result.new(
+        name: "x", status: "nope", detail: "d", fix: nil, bootstrappable: false
+      )
+    end
+  end
+
+  def test_unsupported_host_still_emits_install_guidance
+    with_tmp_dir do |dir|
+      report = build(dir, host_os: "mingw32", skip_web_seed: false).call
+      git = report.results.find { |r| r.name == "git" }
+      # git is present via healthy bins — platform only affects missing fixes.
+      # Force missing:
+      report = Hive::Setup::Diagnostics.new(
+        env: { "HOME" => dir },
+        host_os: "mingw32",
+        home: dir,
+        which: ->(*) { nil },
+        runner: ->(*) { [ "", "", false ] },
+        agent_auth: ->(*) { false },
+        gh_auth: -> { false },
+        app_bundle: Hive::Web::AppBundle.new(
+          data_home: File.join(dir, "data"),
+          env: {},
+          source_app_dir: File.join(dir, "nope"),
+          downloader: ->(*) { },
+          bundler: ->(*) { }
+        )
+      ).call
+      git = report.results.find { |r| r.name == "git" }
+      assert_equal "missing", git.status
+      assert_match(/Linux\/macOS supported/, git.fix)
+    end
+  end
+
+  def test_report_to_h_is_json_serializable
+    with_tmp_dir do |dir|
+      report = build(dir).call
+      json = JSON.generate(report.to_h)
+      parsed = JSON.parse(json)
+      assert_equal "hive-setup-diagnostics", parsed["schema"]
+      assert_equal 11, parsed["results"].length
+      assert parsed["ok"]
+    end
+  end
+
+  private
+
+  def process_alive?(pid)
+    Process.kill(0, pid)
+    true
+  rescue Errno::ESRCH
+    false
+  end
+end
diff --git a/test/unit/web/app_bundle_test.rb b/test/unit/web/app_bundle_test.rb
new file mode 100644
index 00000000..a55fad2c
--- /dev/null
+++ b/test/unit/web/app_bundle_test.rb
@@ -0,0 +1,573 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/web/app_bundle"
+require "digest"
+require "rubygems/package"
+require "zlib"
+
+class WebAppBundleTest < Minitest::Test
+  include HiveTestHelper
+
+  def build(dir, **opts)
+    Hive::Web::AppBundle.new(
+      version: opts.delete(:version) || "9.9.9",
+      data_home: dir,
+      source_app_dir: opts.delete(:source_app_dir),
+      env: opts.delete(:env) || {},
+      downloader: opts.delete(:downloader) || ->(_url, _dest) { raise "network unexpected" },
+      bundler: opts.delete(:bundler) || ->(_app_dir) { nil },
+      **opts
+    )
+  end
+
+  def write_rails_tree(root)
+    FileUtils.mkdir_p(File.join(root, "config"))
+    File.write(File.join(root, "config", "application.rb"), "# rails marker\n")
+    File.write(File.join(root, "Gemfile"), "source 'https://rubygems.org'\n")
+    root
+  end
+
+  def build_tar_gz(archive_path, entries)
+    # entries: { "path" => content_string_or_nil_for_dir }
+    Zlib::GzipWriter.open(archive_path) do |gz|
+      Gem::Package::TarWriter.new(gz) do |tar|
+        entries.each do |name, content|
+          if content.nil?
+            tar.mkdir(name, 0o755)
+          else
+            tar.add_file_simple(name, 0o644, content.bytesize) { |io| io.write(content) }
+          end
+        end
+      end
+    end
+  end
+
+  def checksum_for(path, basename: File.basename(path))
+    "#{Digest::SHA256.file(path).hexdigest}  #{basename}\n"
+  end
+
+  def test_current_stamped_bundle_reused_without_network_or_bundler
+    with_tmp_dir do |dir|
+      target = File.join(dir, "web-app")
+      write_rails_tree(target)
+      File.write(File.join(target, ".hive-web-version"), "9.9.9\n")
+
+      network_called = false
+      bundler_called = false
+      bundle = build(
+        dir,
+        downloader: ->(*) { network_called = true },
+        bundler: ->(*) { bundler_called = true }
+      )
+
+      path = bundle.resolve!
+      assert_equal target, path
+      refute network_called
+      refute bundler_called
+      assert bundle.current_managed_bundle?
+    end
+  end
+
+  def test_missing_bundle_installs_from_directory_fixture_and_stamps_version
+    with_tmp_dir do |dir|
+      source = write_rails_tree(File.join(dir, "fixture-app"))
+      bundler_dirs = []
+      bundle = build(dir, bundler: ->(app_dir) { bundler_dirs << app_dir })
+
+      path = bundle.ensure_installed!(source: source)
+      assert_equal File.join(dir, "web-app"), path
+      assert File.file?(File.join(path, "config", "application.rb"))
+      assert_equal "9.9.9", File.read(File.join(path, ".hive-web-version")).strip
+      assert_equal 1, bundler_dirs.length
+      assert_match(/web-app\.staging\./, bundler_dirs.first,
+                   "bundle install must run against the staging tree before the atomic swap")
+      assert bundle.current_managed_bundle?
+    end
+  end
+
+  def test_install_from_release_archive_records_version
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "hive-web-9.9.9.tar.gz")
+      build_tar_gz(archive, {
+        "config/" => nil,
+        "config/application.rb" => "# rails\n",
+        "Gemfile" => "source 'https://rubygems.org'\n"
+      })
+
+      downloader = lambda do |url, dest|
+        case File.basename(url)
+        when "SHA256SUMS"
+          File.write(dest, checksum_for(archive))
+        when "hive-web-9.9.9.tar.gz"
+          FileUtils.cp(archive, dest)
+        else
+          flunk "unexpected download: #{url}"
+        end
+      end
+      bundle = build(dir, downloader: downloader)
+      path = bundle.ensure_installed!
+      assert_equal "9.9.9", File.read(File.join(path, ".hive-web-version")).strip
+      assert File.file?(File.join(path, "config", "application.rb"))
+    end
+  end
+
+  def test_release_install_rejects_checksum_mismatch_before_extraction
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "hive-web-9.9.9.tar.gz")
+      build_tar_gz(archive, {
+        "config/" => nil,
+        "config/application.rb" => "# rails\n",
+        "Gemfile" => "source 'https://rubygems.org'\n"
+      })
+      downloader = lambda do |url, dest|
+        if File.basename(url) == "SHA256SUMS"
+          File.write(dest, "#{'0' * 64}  hive-web-9.9.9.tar.gz\n")
+        else
+          FileUtils.cp(archive, dest)
+        end
+      end
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        build(dir, downloader: downloader).ensure_installed!
+      end
+
+      assert_match(/checksum mismatch/, error.message)
+      refute File.exist?(File.join(dir, "web-app"))
+    end
+  end
+
+  def test_release_install_requires_an_exact_checksum_entry
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "hive-web-9.9.9.tar.gz")
+      build_tar_gz(archive, { "config/application.rb" => "# rails\n" })
+      downloader = lambda do |url, dest|
+        if File.basename(url) == "SHA256SUMS"
+          File.write(dest, "#{Digest::SHA256.file(archive).hexdigest}  another-file.tar.gz\n")
+        else
+          FileUtils.cp(archive, dest)
+        end
+      end
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        build(dir, downloader: downloader).ensure_installed!
+      end
+      assert_match(/does not contain hive-web-9\.9\.9\.tar\.gz/, error.message)
+    end
+  end
+
+  def test_release_install_verifies_signed_checksums_when_cosign_is_available
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "hive-web-9.9.9.tar.gz")
+      build_tar_gz(archive, {
+        "config/application.rb" => "# rails\n",
+        "Gemfile" => "source 'https://rubygems.org'\n"
+      })
+      bin = File.join(dir, "bin")
+      FileUtils.mkdir_p(bin)
+      cosign = File.join(bin, "cosign")
+      File.write(cosign, "#!/bin/sh\nexit 0\n")
+      FileUtils.chmod(0o755, cosign)
+      downloads = []
+      downloader = lambda do |url, dest|
+        downloads << File.basename(url)
+        case File.basename(url)
+        when "SHA256SUMS"
+          File.write(dest, checksum_for(archive))
+        when "SHA256SUMS.sig", "SHA256SUMS.pem"
+          File.write(dest, "provenance\n")
+        when "hive-web-9.9.9.tar.gz"
+          FileUtils.cp(archive, dest)
+        else
+          flunk "unexpected download: #{url}"
+        end
+      end
+      success = Struct.new(:success?).new(true)
+      verify_argv = nil
+      runner = lambda do |*argv, **kwargs|
+        verify_argv = argv
+        assert_equal 60, kwargs.fetch(:timeout)
+        [ "Verified OK", "", success ]
+      end
+
+      bundle = build(
+        dir,
+        env: { "PATH" => bin },
+        downloader: downloader,
+        subprocess_runner: runner
+      )
+      bundle.ensure_installed!
+
+      assert_equal(
+        %w[SHA256SUMS SHA256SUMS.sig SHA256SUMS.pem hive-web-9.9.9.tar.gz],
+        downloads
+      )
+      assert_equal cosign, verify_argv.first
+      assert_includes verify_argv, "--certificate-identity-regexp"
+      assert_includes verify_argv, "^https://github\\.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/"
+      assert_includes verify_argv, "https://token.actions.githubusercontent.com"
+    end
+  end
+
+  def test_failed_refresh_leaves_previous_bundle_intact
+    with_tmp_dir do |dir|
+      target = File.join(dir, "web-app")
+      write_rails_tree(target)
+      File.write(File.join(target, ".hive-web-version"), "9.9.9\n")
+      File.write(File.join(target, "keep-me"), "old\n")
+
+      source = write_rails_tree(File.join(dir, "new-source"))
+      File.write(File.join(source, "new-file"), "new\n")
+
+      bundle = build(
+        dir,
+        version: "10.0.0",
+        bundler: ->(_app_dir) { raise Hive::Web::AppBundle::Error, "bundle explode" }
+      )
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.ensure_installed!(source: source)
+      end
+      assert_match(/bundle explode/, error.message)
+
+      assert File.exist?(target), "previous bundle must remain"
+      assert_equal "old\n", File.read(File.join(target, "keep-me"))
+      assert_equal "9.9.9", File.read(File.join(target, ".hive-web-version")).strip
+      refute File.exist?(File.join(target, "new-file"))
+      refute Dir.glob(File.join(dir, "web-app.staging.*")).any?
+      refute Dir.glob(File.join(dir, "web-app.prev.*")).any?
+    end
+  end
+
+  def test_failed_first_install_leaves_no_half_provisioned_target
+    with_tmp_dir do |dir|
+      source = write_rails_tree(File.join(dir, "fixture-app"))
+      bundle = build(
+        dir,
+        bundler: ->(_app_dir) { raise Hive::Web::AppBundle::Error, "no gems" }
+      )
+
+      assert_raises(Hive::Web::AppBundle::Error) { bundle.ensure_installed!(source: source) }
+      refute File.exist?(File.join(dir, "web-app"))
+      refute Dir.glob(File.join(dir, "web-app.staging.*")).any?
+    end
+  end
+
+  def test_archive_rejects_path_traversal
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "bad.tar.gz")
+      build_tar_gz(archive, {
+        "../escape/config/application.rb" => "# no\n"
+      })
+      bundle = build(dir)
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.extract_and_install!(archive)
+      end
+      assert_match(/escapes staging root|absolute path/, error.message)
+      refute File.exist?(File.join(dir, "web-app"))
+    end
+  end
+
+  def test_archive_rejects_absolute_paths
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "abs.tar.gz")
+      build_tar_gz(archive, {
+        "/tmp/evil/config/application.rb" => "# no\n"
+      })
+      bundle = build(dir)
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.extract_and_install!(archive)
+      end
+      assert_match(/absolute path/, error.message)
+    end
+  end
+
+  def test_archive_rejects_symlinks
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "link.tar.gz")
+      Zlib::GzipWriter.open(archive) do |gz|
+        Gem::Package::TarWriter.new(gz) do |tar|
+          tar.add_symlink("link-to-elsewhere", "/etc/passwd", 0o777)
+        end
+      end
+      bundle = build(dir)
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.extract_and_install!(archive)
+      end
+      assert_match(/link entry/, error.message)
+    end
+  end
+
+  def test_archive_rejects_missing_rails_marker
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "empty.tar.gz")
+      build_tar_gz(archive, {
+        "README.md" => "no rails here\n"
+      })
+      bundle = build(dir)
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.extract_and_install!(archive)
+      end
+      assert_match(/does not contain a Rails app/, error.message)
+    end
+  end
+
+  def test_archive_rejects_too_many_entries
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "many.tar.gz")
+      build_tar_gz(archive, {
+        "config/" => nil,
+        "config/application.rb" => "# rails\n",
+        "Gemfile" => "source 'https://rubygems.org'\n"
+      })
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        build(dir, max_archive_entries: 2).extract_and_install!(archive)
+      end
+      assert_match(/too many entries/, error.message)
+    end
+  end
+
+  def test_archive_rejects_oversized_compressed_input
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "oversized.tar.gz")
+      build_tar_gz(archive, { "config/application.rb" => "# rails\n" })
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        build(dir, max_archive_bytes: 1).extract_and_install!(archive)
+      end
+      assert_match(/archive is too large/, error.message)
+    end
+  end
+
+  def test_archive_rejects_an_oversized_entry
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "large-entry.tar.gz")
+      build_tar_gz(archive, { "config/application.rb" => "12345" })
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        build(dir, max_archive_entry_bytes: 4).extract_and_install!(archive)
+      end
+      assert_match(/entry is too large/, error.message)
+    end
+  end
+
+  def test_archive_rejects_excessive_cumulative_expansion
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "expanded.tar.gz")
+      build_tar_gz(archive, {
+        "config/application.rb" => "1234",
+        "Gemfile" => "5678"
+      })
+
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        build(dir, max_archive_expanded_bytes: 7).extract_and_install!(archive)
+      end
+      assert_match(/expanded contents are too large/, error.message)
+    end
+  end
+
+  def test_single_versioned_wrapper_directory_is_unwrapped
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "wrapped.tar.gz")
+      build_tar_gz(archive, {
+        "hive-web-9.9.9/" => nil,
+        "hive-web-9.9.9/config/" => nil,
+        "hive-web-9.9.9/config/application.rb" => "# rails\n",
+        "hive-web-9.9.9/Gemfile" => "source 'https://rubygems.org'\n"
+      })
+      bundle = build(dir)
+      path = nil
+      # install via extract path
+      Dir.mktmpdir do |_tmp|
+        path = bundle.tap { |b| b.extract_and_install!(archive) }.managed_app_dir
+      end
+      assert File.file?(File.join(path, "config", "application.rb"))
+      refute File.exist?(File.join(path, "hive-web-9.9.9"))
+    end
+  end
+
+  def test_explicit_env_override_wins_without_managed_bootstrap
+    with_tmp_dir do |dir|
+      override = write_rails_tree(File.join(dir, "override-app"))
+      network_called = false
+      bundle = build(
+        dir,
+        env: { "HIVEBOX_WEB_APP_DIR" => override },
+        downloader: ->(*) { network_called = true }
+      )
+      assert_equal override, bundle.resolve!
+      refute network_called
+      refute File.exist?(File.join(dir, "web-app"))
+    end
+  end
+
+  def test_source_checkout_wins_over_download_when_present
+    with_tmp_dir do |dir|
+      source = write_rails_tree(File.join(dir, "checkout-web"))
+      network_called = false
+      bundle = build(
+        dir,
+        source_app_dir: source,
+        downloader: ->(*) { network_called = true }
+      )
+      assert_equal source, bundle.resolve!
+      refute network_called
+    end
+  end
+
+  def test_default_source_checkout_fallback_resolves_repository_web_directory
+    with_tmp_dir do |dir|
+      bundle = build(dir, source_app_dir: File.join(dir, "missing"))
+      expected = File.expand_path("../../../web", File.join(__dir__, "../../../lib/hive/web"))
+
+      assert_equal expected, bundle.resolve!
+    end
+  end
+
+  def test_runtime_env_is_private_to_the_managed_bundle
+    with_tmp_dir do |dir|
+      bundle = build(dir)
+      managed = bundle.managed_app_dir
+
+      assert_equal(
+        {
+          "BUNDLE_APP_CONFIG" => File.join(managed, ".bundle"),
+          "BUNDLE_GEMFILE" => File.join(managed, "Gemfile"),
+          "BUNDLE_PATH" => File.join(managed, "vendor", "bundle"),
+          "BUNDLE_DEPLOYMENT" => "1",
+          "BUNDLE_WITHOUT" => "development:test",
+          "RAILS_ENV" => "production"
+        },
+        bundle.runtime_env(managed)
+      )
+      assert_empty bundle.runtime_env(File.join(dir, "source-web"))
+    end
+  end
+
+  def test_https_redirect_to_http_is_rejected
+    redirect = Net::HTTPFound.new("1.1", "302", "Found")
+    redirect["location"] = "http://downloads.example.test/hive-web-9.9.9.tar.gz"
+    http_factory = ->(_uri) { FakeHttp.new(redirect) }
+
+    with_tmp_dir do |dir|
+      bundle = Hive::Web::AppBundle.new(
+        version: "9.9.9", data_home: dir, env: {}, http_factory: http_factory,
+        bundler: ->(*) { nil }
+      )
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.send(
+          :default_download,
+          "https://downloads.example.test/hive-web-9.9.9.tar.gz",
+          File.join(dir, "archive"),
+          max_bytes: 100
+        )
+      end
+      assert_match(/refusing HTTPS to HTTP redirect/, error.message)
+    end
+  end
+
+  def test_download_stream_is_bounded_by_explicit_ceiling
+    response = Net::HTTPOK.new("1.1", "200", "OK")
+    response.define_singleton_method(:read_body) do |&block|
+      block.call("1234")
+      block.call("5678")
+    end
+    http_factory = ->(_uri) { FakeHttp.new(response) }
+
+    with_tmp_dir do |dir|
+      dest = File.join(dir, "archive")
+      bundle = Hive::Web::AppBundle.new(
+        version: "9.9.9", data_home: dir, env: {}, http_factory: http_factory,
+        bundler: ->(*) { nil }
+      )
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.send(:default_download, "https://downloads.example.test/archive", dest, max_bytes: 7)
+      end
+      assert_match(/download is too large/, error.message)
+      refute File.exist?(dest)
+    end
+  end
+
+  def test_local_bundle_attempt_honors_timeout_without_retry
+    runner = lambda do |*_args, **kwargs|
+      assert_equal 0.25, kwargs.fetch(:timeout)
+      raise Hive::BoundedSubprocess::TimeoutError.new(stdout: "", stderr: "", elapsed: 0.25)
+    end
+
+    with_tmp_dir do |dir|
+      app = write_rails_tree(File.join(dir, "app"))
+      File.write(File.join(app, "Gemfile.lock"), "GEM\n")
+      bundle = build(dir, subprocess_runner: runner, bundle_timeout: 0.25)
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.send(:default_bundle_install, app)
+      end
+      assert_match(/timed out after 0\.25s/, error.message)
+    end
+  end
+
+  def test_fallback_bundle_attempt_honors_timeout
+    calls = 0
+    failed_status = Struct.new(:success?).new(false)
+    runner = lambda do |*_args, **kwargs|
+      calls += 1
+      assert_equal 0.25, kwargs.fetch(:timeout)
+      return [ "", "not cached", failed_status ] if calls == 1
+
+      raise Hive::BoundedSubprocess::TimeoutError.new(stdout: "", stderr: "", elapsed: 0.25)
+    end
+
+    with_tmp_dir do |dir|
+      app = write_rails_tree(File.join(dir, "app"))
+      File.write(File.join(app, "Gemfile.lock"), "GEM\n")
+      bundle = build(dir, subprocess_runner: runner, bundle_timeout: 0.25)
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.send(:default_bundle_install, app)
+      end
+      assert_equal 2, calls
+      assert_match(/timed out after 0\.25s/, error.message)
+    end
+  end
+
+  def test_release_asset_naming_and_url_agree
+    bundle = build(Dir.mktmpdir)
+    assert_equal "hive-web-9.9.9.tar.gz", bundle.archive_basename
+    assert_equal(
+      "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/releases/download/v9.9.9/hive-web-9.9.9.tar.gz",
+      bundle.release_asset_url
+    )
+  end
+
+  def test_paths_web_app_home_under_data_home
+    with_xdg_home do |_home|
+      assert_equal File.join(Hive::Paths.data_home, "web-app"), Hive::Paths.web_app_home
+      assert_equal File.join(Hive::Paths.web_app_home, ".hive-web-version"),
+                   Hive::Paths.web_app_version_stamp_path
+    end
+  end
+
+  def test_resolve_without_install_raises_when_nothing_available
+    with_tmp_dir do |dir|
+      bundle = build(dir, source_app_dir: File.join(dir, "nope"))
+      # This test runs inside a source checkout, so isolate the no-candidate
+      # branch explicitly rather than depending on the old broken traversal.
+      bundle.define_singleton_method(:rails_app?) { |_path| false }
+      error = assert_raises(Hive::Web::AppBundle::Error) do
+        bundle.resolve!(install: false)
+      end
+      assert_match(/no Rails app found/, error.message)
+    end
+  end
+
+  class FakeHttp
+    attr_accessor :use_ssl, :open_timeout, :read_timeout
+
+    def initialize(response)
+      @response = response
+    end
+
+    def request(_request)
+      yield @response
+    end
+  end
+end
diff --git a/test/unit/web/config_test.rb b/test/unit/web/config_test.rb
index a9ed2389..62f76455 100644
--- a/test/unit/web/config_test.rb
+++ b/test/unit/web/config_test.rb
@@ -10,10 +10,15 @@ class WebConfigTest < Minitest::Test
 
       assert_equal "127.0.0.1", cfg["bind"]
       assert_equal 4567, cfg["port"]
+      assert_equal true, cfg["local_loopback"]
       assert_match(/\.web\.session_secret\z/, cfg["session_secret_file"])
     end
   end
 
+  def test_local_loopback_must_be_boolean
+    assert_web_config_error({ "local_loopback" => "yes" }, /web\.local_loopback/)
+  end
+
   def test_invalid_web_port_is_rejected
     with_tmp_global_config do |home|
       File.write(File.join(home, "config.yml"), { "web" => { "port" => 70_000 } }.to_yaml)
diff --git a/test/unit/web/dispatcher_test.rb b/test/unit/web/dispatcher_test.rb
index a13e62db..caadb20d 100644
--- a/test/unit/web/dispatcher_test.rb
+++ b/test/unit/web/dispatcher_test.rb
@@ -426,4 +426,31 @@ Already answered
                    result[:argv]
     end
   end
+def test_queue_daemon_repair_writes_exact_maintenance_request
+  with_tmp_global_config do
+    request_id = Hive::Web::Dispatcher.new.queue_daemon_repair!
+    files = Dir[File.join(Hive::Paths.state_home, "dispatch_requests", "*.json")]
+    assert files.any?, "repair must write a dispatch request"
+    payload = JSON.parse(File.read(files.find { |f| File.read(f).include?(request_id) }))
+    assert_equal Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT, payload["project"]
+    assert_equal Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG, payload["slug"]
+    assert_equal %w[hive daemon install --force], payload["argv"]
+    assert_equal "daemon_repair", payload["trigger"]
+    assert_equal "bot", payload["requestor"]
+  end
+end
+
+def test_non_maintenance_daemon_argv_rejected_at_write
+  with_tmp_global_config do
+    assert_raises(ArgumentError) do
+      Hive::Daemon::DispatchRequestQueue.write_request!(
+        project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT,
+        slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG,
+        argv: %w[hive daemon start],
+        requestor: "web",
+        trigger: "evil"
+      )
+    end
+  end
+end
 end
diff --git a/test/unit/web/web_command_test.rb b/test/unit/web/web_command_test.rb
index 11de4ab0..a645b674 100644
--- a/test/unit/web/web_command_test.rb
+++ b/test/unit/web/web_command_test.rb
@@ -1,18 +1,18 @@
 require "test_helper"
 require "hive/commands/web"
+require "hive/commands/web/service_installer"
 
 class WebCommandTest < Minitest::Test
   include HiveTestHelper
 
-  # `hive web` now boots the Rails app under web/; outside the container or
-  # a source checkout (no web/ dir, no HIVEBOX_WEB_APP_DIR) it must fail
-  # loudly with guidance instead of exec-ing into a missing app.
+  # `hive web` boots the Rails app via AppBundle; outside managed install,
+  # source checkout, or HIVEBOX_WEB_APP_DIR it must fail loudly.
   def test_missing_rails_app_exits_with_guidance
     with_tmp_global_config do
       with_env("HIVEBOX_WEB_APP_DIR" => File.join(Dir.mktmpdir("hive-noapp"), "nope")) do
         command = Hive::Commands::Web.new
-        # Singleton override instead of minitest/mock (not bundled): the
-        # checkout itself contains web/, so the fallback path would resolve.
+        # Singleton override: the checkout itself contains web/, so the
+        # fallback path would resolve without this stub.
         command.define_singleton_method(:rails_app_dir) { nil }
         err = assert_raises(SystemExit) do
           capture_io { command.call }
@@ -34,6 +34,107 @@ class WebCommandTest < Minitest::Test
     end
   end
 
+  def test_foreground_does_not_invoke_service_manager
+    with_tmp_global_config do
+      with_stub_rails_app(prepare_exit: 0) do
+        original = Kernel.method(:exec)
+        Kernel.define_singleton_method(:exec) do |env, *argv|
+          raise ExecCaught.new(env, argv)
+        end
+        # If service installer is required/used, fail.
+        called = false
+        Hive::Commands::Web::ServiceInstaller.define_singleton_method(:new) do |**|
+          called = true
+          raise "service installer must not run for foreground hive web"
+        end
+
+        begin
+          capture_io { Hive::Commands::Web.new.call }
+        rescue ExecCaught
+          # expected
+        ensure
+          Kernel.define_singleton_method(:exec, original)
+          Hive::Commands::Web::ServiceInstaller.singleton_class.send(:remove_method, :new)
+        end
+        refute called
+      end
+    end
+  end
+
+  def test_json_rejected_for_foreground
+    with_tmp_global_config do
+      error = assert_raises(Hive::InvalidTaskPath) do
+        capture_io { Hive::Commands::Web.new(json: true).call }
+      end
+      assert_match(/no JSON output|long-lived server/, error.message)
+    end
+  end
+
+  def test_install_subcommand_writes_unit
+    with_tmp_dir do |dir|
+      with_env("HOME" => dir) do
+        commands = []
+        installer = Hive::Commands::Web::ServiceInstaller.new(
+          host_os: "linux",
+          home: dir,
+          binary_path: "/tmp/hive",
+          systemctl_available: true,
+          runner: ->(argv) { commands << argv; true }
+        )
+        cmd = Hive::Commands::Web.new(subcommand: "install")
+        cmd.define_singleton_method(:service_installer) { installer }
+        out, = capture_io { cmd.call }
+        assert_match(/installed unit|up to date|upgraded/, out)
+        assert File.exist?(installer.target_path)
+      end
+    end
+  end
+
+  def test_json_status_reports_active_service_as_success
+    installer = Object.new
+    installer.define_singleton_method(:status_snapshot) do
+      {
+        "platform" => "linux", "service_installed" => true,
+        "service_enabled" => true, "service_active" => true,
+        "unit_path" => "/tmp/hive-web.service"
+      }
+    end
+    command = Hive::Commands::Web.new(subcommand: "status", json: true)
+    command.define_singleton_method(:service_installer) { installer }
+
+    out, = capture_io { command.call }
+    payload = JSON.parse(out)
+    assert payload["ok"]
+    assert_nil payload["error"]
+  end
+
+  def test_json_status_reports_inactive_service_and_raises
+    installer = Object.new
+    installer.define_singleton_method(:status_snapshot) do
+      {
+        "platform" => "linux", "service_installed" => true,
+        "service_enabled" => true, "service_active" => false,
+        "unit_path" => "/tmp/hive-web.service"
+      }
+    end
+    command = Hive::Commands::Web.new(subcommand: "status", json: true)
+    command.define_singleton_method(:service_installer) { installer }
+
+    error = nil
+    out, = capture_io do
+      begin
+        command.call
+      rescue Hive::Error => e
+        error = e
+      end
+    end
+    assert_instance_of Hive::Error, error
+    assert_equal "web service not active", error.message
+    payload = JSON.parse(out)
+    refute payload["ok"]
+    assert_equal "web service not active", payload["error"]
+  end
+
   def test_public_bind_without_https_origin_warns
     with_tmp_global_config do
       command = Hive::Commands::Web.new
@@ -115,4 +216,41 @@ class WebCommandTest < Minitest::Test
       end
     end
   end
+
+  def test_managed_bundle_runtime_env_reaches_database_prepare_and_rails
+    with_xdg_home do
+      app_dir = Hive::Web::AppBundle.new.managed_app_dir
+      FileUtils.mkdir_p(File.join(app_dir, "config"))
+      FileUtils.mkdir_p(File.join(app_dir, "bin"))
+      File.write(File.join(app_dir, "config", "application.rb"), "# rails app marker\n")
+      File.write(File.join(app_dir, "Gemfile"), "source \"https://rubygems.org\"\n")
+      File.write(File.join(app_dir, Hive::Web::AppBundle::VERSION_STAMP_NAME), "#{Hive::VERSION}\n")
+      File.write(File.join(app_dir, "bin", "rails"), <<~SH)
+        #!/usr/bin/env bash
+        [ "$1" = "db:prepare" ] || exit 1
+        [ "$BUNDLE_PATH" = "#{File.join(app_dir, 'vendor', 'bundle')}" ] || exit 2
+        exit 0
+      SH
+      FileUtils.chmod(0o755, File.join(app_dir, "bin", "rails"))
+
+      original = Kernel.method(:exec)
+      Kernel.define_singleton_method(:exec) do |env, *argv|
+        raise ExecCaught.new(env, argv)
+      end
+
+      caught = nil
+      begin
+        capture_io { Hive::Commands::Web.new.call }
+      rescue ExecCaught => e
+        caught = e
+      ensure
+        Kernel.define_singleton_method(:exec, original)
+      end
+
+      refute_nil caught
+      assert_equal File.join(app_dir, "vendor", "bundle"), caught.env["BUNDLE_PATH"]
+      assert_equal "1", caught.env["BUNDLE_DEPLOYMENT"]
+      assert_equal File.join(app_dir, "Gemfile"), caught.env["BUNDLE_GEMFILE"]
+    end
+  end
 end
diff --git a/web/Gemfile b/web/Gemfile
index 53710d90..1ef3aaa5 100644
--- a/web/Gemfile
+++ b/web/Gemfile
@@ -70,4 +70,7 @@ end
 # The hive control plane: status payloads, gate approval, daemon dispatch,
 # the GitHub device-flow gate, the agent OAuth relay, and Telegram
 # validation all come from the gem — the web tier adds no pipeline logic.
+# Source checkouts use the sibling gemspec. release.yml rewrites only its
+# tracked staging copy to an exact version and an unpacked vendor/hive-cli
+# path, so the published web archive has no parent-path dependency.
 gem "hive-cli", path: ".."
diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb
index 2a1e5e28..b1749a0f 100644
--- a/web/app/controllers/application_controller.rb
+++ b/web/app/controllers/application_controller.rb
@@ -50,6 +50,8 @@ class ApplicationController < ActionController::Base
   end
 
   def require_login
+    return if local_loopback_request?
+
     return redirect_to login_path unless current_login
 
     # Sessions must track the CURRENT owner, not the owner at sign-in time:
@@ -65,6 +67,21 @@ class ApplicationController < ActionController::Base
     redirect_to login_path, alert: "Signed out: this box's owner changed."
   end
 
+  # Local single-user mode: CLI sets HIVE_WEB_LOCAL_LOOPBACK only for a
+  # loopback bind, and we re-check both the actual peer and the HTTP Host.
+  # The Host check is load-bearing: accepting an arbitrary hostname merely
+  # because its TCP peer is loopback would let DNS rebinding turn a browser
+  # on the operator's machine into an unauthenticated deputy.
+  def local_loopback_request?
+    return false unless Hive::Web::Loopback.mode_enabled?
+    return false unless Hive::Config.load_global_web.fetch("local_loopback", true)
+
+    Hive::Web::Loopback.address?(request.remote_ip) &&
+      Hive::Web::Loopback.address?(request.host)
+  rescue StandardError
+    false
+  end
+
   def registered_projects
     @registered_projects ||= Hive::Config.registered_projects
   end
diff --git a/web/app/controllers/daemon_controller.rb b/web/app/controllers/daemon_controller.rb
new file mode 100644
index 00000000..a6e5d1aa
--- /dev/null
+++ b/web/app/controllers/daemon_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class DaemonController < ApplicationController
+  # POST /daemon/repair — enqueue exact allowlisted
+  # `hive daemon install --force` for a running daemon to consume.
+  def repair
+    require "hive/daemon/status_report"
+    report = Hive::Daemon::StatusReport.new.safe_payload
+
+    unless report["running"]
+      raise Hive::Error,
+            "daemon is not running — repair cannot be queued. " \
+            "Run `hive daemon install --force` from a terminal, then `hive daemon start`."
+    end
+
+    drift = report["binary_drift"].to_s
+    unless %w[path version unparseable].include?(drift)
+      raise Hive::Error,
+            "daemon binary drift is #{drift.inspect}; repair is only offered for " \
+            "path/version/unparseable drift"
+    end
+
+    request_id = Hive::Web::Dispatcher.new.queue_daemon_repair!
+    redirect_to root_path,
+                notice: "Daemon repair queued (#{request_id}). " \
+                        "The running daemon will reinstall its unit with the current binary."
+  end
+end
diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb
index c44440a8..a217f00b 100644
--- a/web/app/controllers/status_controller.rb
+++ b/web/app/controllers/status_controller.rb
@@ -2,5 +2,20 @@ class StatusController < ApplicationController
   def index
     @payload = StatusBroadcaster.snapshot
     @projects = @payload.fetch("projects", [])
+    @daemon = daemon_status_payload
+  end
+
+  private
+
+  def daemon_status_payload
+    require "hive/daemon/status_report"
+    Hive::Daemon::StatusReport.new.safe_payload
+  rescue StandardError => e
+    {
+      "running" => false,
+      "binary_drift" => "unreadable",
+      "error" => "#{e.class}: #{e.message}"
+    }
   end
 end
+
diff --git a/web/app/views/status/_daemon.html.erb b/web/app/views/status/_daemon.html.erb
new file mode 100644
index 00000000..8297f179
--- /dev/null
+++ b/web/app/views/status/_daemon.html.erb
@@ -0,0 +1,61 @@
+<%# Locals: daemon (Hash from StatusReport#safe_payload) %>
+<% daemon = local_assigns.fetch(:daemon) { {} } %>
+<% running = daemon["running"] %>
+<% drift = daemon["binary_drift"].to_s %>
+<% actionable = running && %w[path version unparseable].include?(drift) %>
+<section class="daemon-health" aria-label="Daemon health">
+  <h2 class="daemon-health-title">Daemon</h2>
+  <dl class="daemon-health-grid">
+    <div>
+      <dt>Status</dt>
+      <dd>
+        <% if running %>
+          <span class="badge badge-ok">running</span>
+          <% if daemon["pid"] %>pid <%= daemon["pid"] %><% end %>
+        <% else %>
+          <span class="badge badge-down">down</span>
+        <% end %>
+      </dd>
+    </div>
+    <div>
+      <dt>Service</dt>
+      <dd>
+        <% if daemon["service_installed"] %>
+          installed<%= daemon["service_enabled"] ? ", enabled" : "" %>
+        <% elsif daemon["service_installed"].nil? %>
+          unknown
+        <% else %>
+          not installed
+        <% end %>
+      </dd>
+    </div>
+    <div>
+      <dt>Binary drift</dt>
+      <dd>
+        <code><%= drift.empty? ? "unknown" : drift %></code>
+        <% if daemon["installed_binary"] || daemon["expected_binary"] %>
+          <div class="muted small">
+            installed: <%= daemon["installed_binary"].inspect %><br>
+            expected: <%= daemon["expected_binary"].inspect %><br>
+            installed version: <%= daemon["installed_version"].inspect %> /
+            current: <%= daemon["current_version"].inspect %>
+          </div>
+        <% end %>
+      </dd>
+    </div>
+  </dl>
+
+  <% if actionable %>
+    <%= button_to "Repair daemon unit", daemon_repair_path, method: :post,
+          class: "btn btn-sm",
+          form: { data: { turbo_confirm: "Queue hive daemon install --force on the running daemon?" } } %>
+  <% elsif !running %>
+    <p class="muted small">
+      Daemon is down — repair cannot be queued. Run
+      <code>hive daemon install --force</code> then
+      <code>hive daemon start</code> from a terminal.
+    </p>
+  <% elsif drift == "none" %>
+    <p class="muted small">Daemon unit matches this CLI binary.</p>
+  <% end %>
+</section>
diff --git a/web/app/views/status/index.html.erb b/web/app/views/status/index.html.erb
index 25bcdf79..4135ace7 100644
--- a/web/app/views/status/index.html.erb
+++ b/web/app/views/status/index.html.erb
@@ -26,6 +26,8 @@
   </aside>
   <div class="status-main">
 
+<%= render "status/daemon", daemon: @daemon || {} %>
+
 <%# data-turbo-permanent: a morph must never touch the composer — it holds
     typed-but-unsent idea text and staged image attachments (Stimulus state
     the server can't re-render). %>
diff --git a/web/config/initializers/hive.rb b/web/config/initializers/hive.rb
index f2792b55..12b51250 100644
--- a/web/config/initializers/hive.rb
+++ b/web/config/initializers/hive.rb
@@ -3,6 +3,7 @@
 require "hive"
 require "hive/media_manifest"
 require "hive/web/github_auth"
+require "hive/web/loopback"
 require "hive/web/status_feed"
 require "hive/web/dispatcher"
 require "hive/web/agents_auth"
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 659576df..230524d0 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -17,6 +17,8 @@ Rails.application.routes.draw do
 
   root "status#index"
 
+  post "daemon/repair" => "daemon#repair", as: :daemon_repair
+
   post "ideas" => "ideas#create", as: :ideas
 
   # Task pages are addressed by project name + task slug, mirroring the CLI.
diff --git a/web/test/e2e/golden_path_e2e.rb b/web/test/e2e/golden_path_e2e.rb
index 5d6c8302..0bc5bfe9 100644
--- a/web/test/e2e/golden_path_e2e.rb
+++ b/web/test/e2e/golden_path_e2e.rb
@@ -1,5 +1,7 @@
 require "application_system_test_case"
 require "open3"
+require "hive/tui/bubble_model"
+require "hive/tui/state_source"
 
 # The hivebox golden path, end to end, in a real browser — deliberately NOT
 # named *_test.rb so the default suites skip it; run it explicitly:
@@ -36,6 +38,8 @@ class GoldenPathE2E < ApplicationSystemTestCase
   end
 
   setup do
+    @original_path = ENV["PATH"]
+    ENV["PATH"] = [ File.join(REPO_ROOT, "bin"), @original_path ].compact.join(File::PATH_SEPARATOR)
     configure_owner!(owner: "") # claimable box
     speed_up_daemon!
     @project = create_hive_project!("golden-app")
@@ -52,6 +56,7 @@ class GoldenPathE2E < ApplicationSystemTestCase
     end
     StatusBroadcaster.stop!
     SessionsController.http_client = Net::HTTP
+    ENV["PATH"] = @original_path
     # The sandbox vanishes with the process — on failure, keep the daemon's
     # own event log and the task tree where a human can read them.
     unless passed?
@@ -149,8 +154,66 @@ class GoldenPathE2E < ApplicationSystemTestCase
                     "the fake agent's commit must be a real commit in a real worktree"
   end
 
+  test "TUI and web ideas share one state tree and both auto-dispatch" do
+    sign_in!(login: "goldenpath")
+
+    tui_title = "TUI parity idea"
+    submit_tui_idea!(tui_title)
+    visit "/"
+    assert_selector ".task-row", text: tui_title, wait: 10,
+                    message: "an idea submitted through the real TUI model must appear in Rails"
+    tui_slug = task_slug_from_grid!(tui_title)
+    wait_for_daemon_dispatch!(tui_slug)
+
+    web_title = "Web parity idea"
+    fill_in "New idea", with: web_title
+    find(".composer select[name='project']").find("option[value='#{@project}']").select_option
+    click_button "Add idea"
+    assert_selector ".task-row", text: web_title, wait: 10
+    web_slug = task_slug_from_grid!(web_title)
+
+    snapshot = Hive::Tui::StateSource.new.refresh_now
+    assert snapshot.rows.any? { |row| row.slug == web_slug && row.display_name == web_title },
+           "an idea submitted through Rails must appear in the TUI's real StateSource snapshot"
+    wait_for_daemon_dispatch!(web_slug)
+  end
+
   private
 
+  def submit_tui_idea!(title)
+    snapshot = Hive::Tui::StateSource.new.refresh_now
+    bubble = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial.with(
+        mode: :new_idea,
+        snapshot: snapshot,
+        scope: 0,
+        new_idea_project_name: @project,
+        new_idea_buffer: title,
+        new_idea_cursor: title.length
+      )
+    )
+
+    capture_io { bubble.update(Hive::Tui::Messages::NEW_IDEA_SUBMITTED) }
+    assert_equal :grid, bubble.hive_model.mode
+    assert_match(/\A\+ /, bubble.hive_model.flash.to_s,
+                 "the TUI must report a successful real `hive new` subprocess")
+  end
+
+  def wait_for_daemon_dispatch!(slug, timeout: 30)
+    events = File.join(ENV["HIVE_HOME"], "logs", "daemon.log")
+    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+    loop do
+      log = File.exist?(events) ? File.read(events) : ""
+      return if log.lines.any? { |line| line.include?(slug) && line.include?('"event":"dispatched"') }
+
+      if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
+        raise "daemon never auto-dispatched #{slug}"
+      end
+
+      sleep 0.1
+    end
+  end
+
   # The status grid is Turbo-replaced while the daemon advances tasks. Read the
   # slug from a single current-DOM query instead of retaining a Capybara element.
   def task_slug_from_grid!(title, timeout: 10)
diff --git a/web/test/integration/local_loopback_auth_test.rb b/web/test/integration/local_loopback_auth_test.rb
new file mode 100644
index 00000000..6f84bb7c
--- /dev/null
+++ b/web/test/integration/local_loopback_auth_test.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class LocalLoopbackAuthTest < ActionDispatch::IntegrationTest
+  setup do
+    @previous_mode = ENV[Hive::Web::Loopback::ENV_MODE]
+  end
+
+  teardown do
+    if @previous_mode.nil?
+      ENV.delete(Hive::Web::Loopback::ENV_MODE)
+    else
+      ENV[Hive::Web::Loopback::ENV_MODE] = @previous_mode
+    end
+  end
+
+  test "loopback peer with mode enabled reaches protected page without session" do
+    ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+    # Default remote_ip in integration tests is 127.0.0.1
+    get root_path
+    assert_response :success
+    assert_nil session[:github_login]
+  end
+
+  test "local_loopback false restores login redirect even when mode env is set" do
+    ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+    path = Hive::Config.global_config_path
+    FileUtils.mkdir_p(File.dirname(path))
+    existing = File.exist?(path) ? YAML.safe_load(File.read(path)) || {} : {}
+    existing = {} unless existing.is_a?(Hash)
+    existing["web"] = (existing["web"] || {}).merge("local_loopback" => false)
+    File.write(path, existing.to_yaml)
+
+    get root_path
+    assert_redirected_to login_path
+  ensure
+    # Leave config as tests usually use isolated HIVE_HOME; best-effort restore.
+    if defined?(path) && File.exist?(path)
+      data = YAML.safe_load(File.read(path)) || {}
+      if data.is_a?(Hash) && data["web"].is_a?(Hash)
+        data["web"].delete("local_loopback")
+        File.write(path, data.to_yaml)
+      end
+    end
+  end
+
+  test "non-loopback peer never bypasses login even with mode env" do
+    ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+    get root_path, headers: { "REMOTE_ADDR" => "8.8.8.8" }
+    assert_redirected_to login_path
+  end
+
+  test "non-loopback Host never bypasses login for a loopback peer" do
+    ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+    get root_path, headers: { "HOST" => "attacker.example" }
+    assert_redirected_to login_path
+  end
+
+  test "loopback Host with an explicit port keeps the local bypass" do
+    ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+    get root_path, headers: { "HOST" => "127.0.0.1:4567" }
+    assert_response :success
+  end
+
+  test "mode disabled requires login on loopback" do
+    ENV.delete(Hive::Web::Loopback::ENV_MODE)
+    get root_path
+    assert_redirected_to login_path
+  end
+end
diff --git a/wiki/commands.md b/wiki/commands.md
index e69836e5..9f7f8f36 100644
--- a/wiki/commands.md
+++ b/wiki/commands.md
@@ -3,19 +3,19 @@ title: Interaction Surface
 type: commands
 source: bin/hive, bin/hv, bin/hive-e2e, lib/hive/cli.rb, lib/hive/commands/connect.rb, lib/hive/commands/disconnect.rb, lib/hive/commands/bench_submit.rb, lib/hive/commands/digest.rb, lib/hive/digest.rb, lib/hive/digest/, lib/hive/web/, public/, hive.gemspec, packaging/docker/, .github/workflows/release.yml, openclaw/skills/hive/SKILL.md, openclaw/README.md
 created: 2026-05-14
-updated: 2026-06-22
+updated: 2026-07-15
 tags: [commands, api]
 ---
 
 **TLDR**: Hive's external interaction surface is the Thor CLI (`hive` plus the
-`hv` fallback launcher), the opt-in e2e harness, the hivebox web command/routes
-documented in [[commands/web]], `hive connect screenote` as the Screenote OAuth
-setup surface for artifacts MCP uploads, `hive bench submit` as the hive-bench
-corpus producer, `hive digest` as the daily shipped digest producer, and the
-single ClawHub `hive-cli` OpenClaw skill whose installed slash command is `/hive`.
-The Ruby command/API contract lives in [[cli]] and the
-per-command pages. OpenClaw does not add a second runtime and does not publish
-one ClawHub listing per Hive verb.
+`hv` fallback launcher), the opt-in e2e harness, local `hive setup` / `hive web`
+(and hivebox Docker) documented in [[commands/setup]] and [[commands/web]],
+`hive connect screenote` as the Screenote OAuth setup surface for artifacts MCP
+uploads, `hive bench submit` as the hive-bench corpus producer, `hive digest` as
+the daily shipped digest producer, and the single ClawHub `hive-cli` OpenClaw
+skill whose installed slash command is `/hive`. The Ruby command/API contract
+lives in [[cli]] and the per-command pages. OpenClaw does not add a second
+runtime and does not publish one ClawHub listing per Hive verb.
 
 ## Source Files
 
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index c84cb08e..1019d6b2 100644
--- a/wiki/commands/daemon.md
+++ b/wiki/commands/daemon.md
@@ -3,7 +3,7 @@ title: hive daemon
 type: command
 source: lib/hive/commands/daemon.rb, lib/hive/daemon/*
 created: 2026-05-06
-updated: 2026-06-18
+updated: 2026-07-15
 tags: [command, daemon, automation, json]
 ---
 
@@ -40,13 +40,13 @@ hive daemon queue   [list | show <id> | prune]  [--json]
 |-----------|----------|
 | `start`    | Acquires the PID file (`~/Dev/hive/.daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. |
 | `stop`     | Sends `SIGTERM` to the running daemon's PID. Waits up to `daemon.shutdown_grace_sec` (default 600s) for the daemon to exit, then escalates to `SIGKILL`. Idempotent: `stop` with no PID file exits 0 with `daemon not running` on stderr; a stale PID file (process gone) is removed and the call exits 0. With `--json`, emits a `hive-daemon-stop` envelope (fields: `running`, `was_running`, `stale_pid?`, `reason?` — `pid_reused` / `unverified` for safety bailouts). |
-| `status`   | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`, plus the autostart-service state `service_installed`, `service_enabled`, and `unit_path` (read-only probe) so an agent can tell whether `hive daemon install` has run without a mutating call. |
+| `status`   | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`, autostart-service state (`service_installed`, `service_enabled`, `unit_path`), and **binary drift** fields (`expected_binary`, `installed_binary`, `installed_version`, `binary_drift` enum: `none` / `path` / `version` / `unparseable` / `unreadable` / `not_applicable`). The shared `StatusReport` (also used by Rails) verifies pid ownership/start time rather than trusting `kill(0)`, and its version child uses the process-group-bounded probe; timeout, spawn/non-zero, malformed, and non-executable results are actionable `unreadable`, never `none`. Text mode prints a repair hint (`hive daemon install --force`) when drift is actionable. |
 | `reload`   | Sends `SIGHUP` to the running daemon's PID, which triggers config reload at the next tick boundary. In-flight children continue uninterrupted. Exit 1 if no daemon running. With `--json`, emits a `hive-daemon-reload` envelope (`ok`, `reason`, `pid`, `message`). |
 | `tail`     | `tail -F` semantics on `~/Dev/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. |
 | `install`  | (Re)writes the platform-native unit file (`~/.config/systemd/user/hive-daemon.service` on Linux, `~/Library/LaunchAgents/local.hive-daemon.plist` on macOS) and starts/enables the service. Installers and agent-assisted setup run this by default so daemon autostart is global install-time infrastructure, independent of any project. Without `--force`, refuses to overwrite a pre-existing unit (preserving operator hand-edits); exit `64` (USAGE) with a message pointing at `--force` so automation can branch without clobbering local changes. With `--force`, saves the previous content to a timestamped `<path>.bak-YYYYMMDDTHHMMSSZ` (rotated, never overwritten) via atomic write, then — only when an existing unit was actually overwritten (the `upgraded` outcome) — restarts the running daemon on Linux / unloads-then-loads on macOS so new `Environment=` lines take effect (a first-time `--force` install with no prior unit just starts/enables, no restart). A service-manager failure (systemctl reload/enable, or launchctl load rejecting the unit) exits `70` (SOFTWARE). A host with no systemd-user manager at all is different: the unit is still written, but autostart cannot be enabled, so it exits `0` with the `unsupported` outcome (and `target_path` set to the written unit) — a known-platform limitation, not a failure. With `--json`, every outcome (success and error) emits a `hive-daemon-install.v1` envelope. Units point at the user-facing wrapper path when installers provide it, so bash/Homebrew installs preserve the GEM_HOME/GEM_PATH wrapper across login/reboot; `hv` invocations remain valid when Apache Hive shadows `hive`. Use this after upgrading hive when the unit template has changed or when autostart needs repair. |
 | `enable`   | Sets `daemon.enabled: true` in `<project>/.hive-state/config.yml`. This enrolls a project for dispatch; it does not install, start, or autostart the global daemon service. Surgical line-level YAML editor (upsert) preserves comments, key order, and file-mode bits across enable/disable flips; rejects inline-flow `daemon: { ... }`, CRLF endings, and 4-space-indented children before any write. Atomic write goes via tempfile + `flock(LOCK_EX)` + `fsync` + rename; tempfile is ensure-cleaned on rename failure (ENOSPC / EACCES / EXDEV). Pre-flight (`preflight_targets`) validates every target before any write so `--all` cannot half-flip the registry on a bad middle project. Pass a registered project name OR `--all` (mutually exclusive — passing both raises USAGE 64). Exit 64 on missing/unknown target / not-initialised project / no registered projects. With `--json`, emits a `hive-daemon-enroll` envelope on success and an `EnrollErrorKind` JSON error envelope on failure (`missing_project` / `unknown_project` / `project_and_all` / `not_initialised` / `no_projects` / `config` / `internal`); YAML parse failures surface as `Hive::ConfigError` (exit 78). |
 | `disable`  | Same shape as `enable`, sets `daemon.enabled: false`. The next dispatcher tick honours the change automatically (per-tick enable-cache invalidation); `hive daemon reload` is optional for instant pickup. |
-| `queue`    | Read-only inspection of the dispatch-request queue the bot/web producers and `3-plan` healer write and the daemon consumes. Runs in the CLI process (no daemon contact); reads the same `<state_home>/dispatch_requests/` directory. Current pending request files use `hive-dispatch-request.v2`, whose `requestor` enum is `bot|healer`; older/wrong versions are reported as malformed and pruned like other bad files. `list` (default) prints each pending request with `request_id  age  project/slug  verb` plus `[EXPIRED]` / `[NOT-ALLOWLISTED]` flags and any malformed files. `show <id>` dumps one request's full payload (errors with exit 1 if the id is unknown; missing id is a USAGE error). `prune` removes expired + malformed request files (the daemon also does this lazily on its own tick) and reports the count. With `--json`, emits a `hive-daemon-queue.v1` envelope (`action`, `requests[]`, `request`, `malformed[]`, `pruned_count`). Unknown actions, missing `show` request ids, and unexpected queue-command exceptions emit the schema's `ErrorPayload` arm with `ok:false`, `error_kind` (`unknown_action` / `missing_request_id` / `internal`), and `message` before exiting non-zero. Claimed in-flight requests (`*.json.claimed`) are intentionally not listed — they are daemon-managed; see [[modules/daemon]] §"At-most-once dispatch via atomic claim". |
+| `queue`    | Read-only inspection of the dispatch-request queue the bot/web producers and `3-plan` healer write and the daemon consumes. Runs in the CLI process (no daemon contact); reads the same `<state_home>/dispatch_requests/` directory. Current pending request files use `hive-dispatch-request.v2`, whose `requestor` enum is `bot|healer`; web repair uses the schema-valid `bot` value. Older/wrong versions are reported as malformed and pruned like other bad files. `list` (default) prints each pending request with `request_id  age  project/slug  verb` plus `[EXPIRED]` / `[NOT-ALLOWLISTED]` flags and any malformed files. The host-maintenance argv is valid only with the exact `__hive_host__/host-maintenance` sentinel tuple; it cannot ride an enabled real project. At spawn, that repair alone resolves the current installer binary instead of substituting the running daemon's potentially stale `HIVE_BIN`. `show <id>` dumps one request's full payload (errors with exit 1 if the id is unknown; missing id is a USAGE error). `prune` removes expired + malformed request files (the daemon also does this lazily on its own tick) and reports the count. With `--json`, emits a `hive-daemon-queue.v1` envelope (`action`, `requests[]`, `request`, `malformed[]`, `pruned_count`). Unknown actions, missing `show` request ids, and unexpected queue-command exceptions emit the schema's `ErrorPayload` arm with `ok:false`, `error_kind` (`unknown_action` / `missing_request_id` / `internal`), and `message` before exiting non-zero. Claimed in-flight requests (`*.json.claimed`) are intentionally not listed — they are daemon-managed; see [[modules/daemon]] §"At-most-once dispatch via atomic claim". |
 
 ## Global Digest
 
diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md
new file mode 100644
index 00000000..3954edad
--- /dev/null
+++ b/wiki/commands/setup.md
@@ -0,0 +1,48 @@
+---
+title: hive setup
+type: command
+source: lib/hive/commands/setup.rb, lib/hive/setup/diagnostics.rb
+created: 2026-07-15
+updated: 2026-07-15
+tags: [command, setup, web, daemon, local]
+---
+
+**TLDR**: `hive setup` is the local (non-Docker) provisioner. It diagnoses
+host dependencies, bootstraps Hive-owned assets (qmd + version-matched Rails
+web bundle), installs/starts `hive-daemon` with the invoking CLI binary,
+initializes or enrolls the current repository, and optionally installs the
+separate `hive-web` service when `--service` is passed.
+
+## CLI
+
+```bash
+hive setup [--service] [--no-bootstrap] [--no-init] [--json]
+```
+
+| Flag | Effect |
+|---|---|
+| `--service` | Also install/start the managed `hive-web` service (systemd-user / launchd). |
+| `--no-bootstrap` | Diagnose only; no npm, download, service manager, init, or enrollment mutations. |
+| `--no-init` | Skip repository init/enrollment; still provision services when not diagnose-only. |
+| `--json` | Emit a complete diagnostics + phases report; `ok` and exit reflect all failures. |
+
+## Phases
+
+1. **diagnostics** — bounded probes for Ruby 3.4, git, tmux, gh, claude, codex, Node, npm, qmd, web bundle, SQLite. Probe children run in their own process groups and a deadline terminates descendants as well as the direct child, so inherited capture pipes cannot strand setup. Only qmd and the web bundle are bootstrappable; external CLIs get exact fix commands and are never silently installed/authenticated.
+2. **bootstrap_qmd** — `npm install --global --prefix "$XDG_DATA_HOME/hive/qmd" @tobilu/qmd` when missing.
+3. **bootstrap_web_bundle** — `Hive::Web::AppBundle` installs `hive-web-<version>.tar.gz` under XDG data.
+4. **daemon_install** — `hive-daemon` unit with `Hive::InvokedBinary.path`, force-refresh on drift.
+5. **init / enroll** — fresh repo runs `hive init` (coding workflow, non-interactive); existing `.hive-state` runs `hive daemon enable`. Both commands use non-emitting programmatic APIs, so `--json` writes exactly one `hive-setup.v1` document; even a nested `SystemExit` becomes a failed phase and the complete report is still emitted.
+6. **web_install** (only with `--service`) — separate `hive-web` unit running `hive web`. When setup refreshed the bundle and the service was already active, it explicitly restarts the unit and waits for bounded `/health` success before reporting the phase green.
+
+Bare setup (no `--service`) ends with a reminder to run foreground `hive web`.
+With `--service`, reports `http://127.0.0.1:4567`.
+`hive-setup.v1.json`, `hive-web-install.v1.json`, and
+`hive-web-status.v1.json` are published contracts registered in
+`Hive::Schemas::SCHEMA_VERSIONS`.
+
+## Related
+
+- [[commands/web]] — foreground and managed web lifecycle
+- [[commands/daemon]] — daemon install/status/binary drift
+- [[operating]] — XDG paths and autostart
diff --git a/wiki/commands/web.md b/wiki/commands/web.md
index f6f25373..8a937cac 100644
--- a/wiki/commands/web.md
+++ b/wiki/commands/web.md
@@ -3,8 +3,8 @@ title: hive web
 type: command
 source: lib/hive/commands/web.rb, lib/hive/web/, web/, packaging/docker/, .github/workflows/release.yml
 created: 2026-06-04
-updated: 2026-06-25
-tags: [command, web, hivebox, rails, turbo]
+updated: 2026-07-15
+tags: [command, web, hivebox, rails, turbo, local]
 ---
 
 **TLDR**: `hive web` boots the hivebox web UI — a vanilla **Rails 8** app
@@ -20,19 +20,60 @@ Retry button and Telegram Autofix share the same guarded clear plus rerun
 contract; the TUI's Recover has its own subprocess-based clear + `hive run`
 path with separate gates.
 
+
+## Local install mode (peer to hivebox)
+
+Local non-Docker installs are first-class and share the operator's real XDG
+state and enrolled repositories with the TUI and daemon:
+
+```bash
+hive setup                 # diagnose + bootstrap + daemon + enroll
+hive setup --service       # also managed hive-web on http://127.0.0.1:4567
+hive web                   # foreground Rails (no unit required)
+hive web install|start|stop|status
+```
+
+App resolution: `HIVEBOX_WEB_APP_DIR` → managed XDG bundle
+(`Hive::Web::AppBundle` / `hive-web-<version>.tar.gz`) → source `web/` →
+download. The gem excludes `web/`. Default bind is loopback with optional
+no-auth (`web.local_loopback`); the bypass requires both a loopback peer and a
+loopback Host, closing DNS-rebinding access through attacker-controlled host
+names. Non-loopback requires owner or `--unsafe`.
+Dashboard shows daemon drift and can queue `hive daemon install --force`
+via the allowlisted host-maintenance dispatch request. See [[commands/setup]].
+
 ## CLI
 
-`hive web [--bind] [--port]` (defaults from the `web:` config block). The
-command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/`
-next to `lib/`), exports `SECRET_KEY_BASE` (derived from the same persisted
-`Hive::Web::SessionSecret` file as before — sessions survive container
-recreation), `HIVEBOX_ORIGIN` (extra Action Cable origin allow; same-origin
-host traffic is accepted without config), and
-`HIVEBOX_STORAGE_DIR` (the solid-stack sqlite files, under
-`Hive::Paths.state_home/web-storage` so they live on the `/data` mount), runs
-`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container
-or a source checkout the command exits 1 with guidance — the gem itself does
-not package the Rails app (`test/unit/gemspec_test.rb` pins that).
+```bash
+hive web [--bind] [--port] [--unsafe]   # foreground
+hive web install [--force] [--json]
+hive web start | stop | status [--json]
+```
+
+Defaults come from the `web:` config block. Bare `hive web` locates the Rails
+app (`HIVEBOX_WEB_APP_DIR` → managed XDG bundle → source `web/` → download),
+exports `SECRET_KEY_BASE` (from the persisted `Hive::Web::SessionSecret` file),
+`HIVEBOX_ORIGIN`, and `HIVEBOX_STORAGE_DIR` (solid-stack sqlite under
+`Hive::Paths.state_home/web-storage`), runs `bin/rails db:prepare`, then execs
+`bin/rails server`. The gem still excludes `web/` (`test/unit/gemspec_test.rb`);
+local gem installs use the release archive via `AppBundle`.
+
+The release archive is standalone: release staging replaces the checkout-only
+`gem "hive-cli", path: ".."` with an exact-version contained
+`vendor/hive-cli` source, regenerates the lock, and performs an isolated
+deployment install before upload. Downloads stream to disk with compressed,
+entry-count, per-entry, and cumulative-expanded size ceilings; reject
+HTTPS→HTTP redirects, traversal, and link entries; and match the asset against
+the release `SHA256SUMS` (plus pinned GitHub Actions identity/issuer cosign
+verification when cosign is installed, matching `install.sh` policy). Managed
+Bundler installs have a hard deadline and persist `BUNDLE_PATH` plus the other
+deployment variables into `db:prepare` and the final Rails exec; source and
+Docker trees keep their normal environment.
+
+`hive web status --json` emits `ok:false` plus
+`error:"web service not active"` and exits non-zero when inactive, matching
+text lifecycle semantics. Its contract and the install contract are published
+as `hive-web-status.v1.json` and `hive-web-install.v1.json`.
 
 ## Auth
 
@@ -244,6 +285,10 @@ browser-visible Demo gallery images and failed-capture banner states. CI runs
 both in the `web` job (`.github/workflows/ci.yml`) plus the web app's own
 rubocop, and it explicitly runs `web/test/e2e/golden_path_e2e.rb`; the golden
 path's daemon/Turbo row-replacement retry contract is covered in [[testing]].
+The same job also runs the opt-in live local-setup acceptance: a fresh git repo,
+real managed AppBundle/Bundler install, independent real daemon and Rails
+process groups, deep health, and bidirectional TUI/web creation with automatic
+daemon dispatch against one state tree.
 
 `web/script/record_box_demo.rb` is a manual demo recorder, not a test. It
 stages a temporary local repo, boots the real Rails app and real `hive daemon`,
diff --git a/wiki/index.md b/wiki/index.md
index d59b93e3..98bf716e 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -42,6 +42,7 @@ Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descr
 - [[commands/rebase-status]] — `wiki/commands/rebase-status.md`
 - [[commands/run]] — `wiki/commands/run.md`
 - [[commands/screenote]] — `wiki/commands/screenote.md`
+- [[commands/setup]] — `wiki/commands/setup.md`
 - [[commands/stage_action]] — `wiki/commands/stage_action.md`
 - [[commands/status]] — `wiki/commands/status.md`
 - [[commands/tui]] — `wiki/commands/tui.md`
diff --git a/wiki/log.d/20260715T031500Z-local-web-setup.md b/wiki/log.d/20260715T031500Z-local-web-setup.md
new file mode 100644
index 00000000..7d365167
--- /dev/null
+++ b/wiki/log.d/20260715T031500Z-local-web-setup.md
@@ -0,0 +1,15 @@
+---
+title: Local Hive web install and run
+date: 2026-07-15
+tags: [web, setup, daemon, local]
+---
+
+Added first-class local (non-Docker) install/run for the Rails UI:
+
+- `Hive::Web::AppBundle` installs version-matched `hive-web-<version>.tar.gz` under XDG data; gem stays web-free; release workflow attaches the archive.
+- `Hive::Setup::Diagnostics` + `hive setup [--service]` provision qmd, web bundle, daemon (InvokedBinary), project enroll, optional hive-web service.
+- Independent `hive web install|start|stop|status` via ServiceInstaller sibling; foreground `hive web` preserved.
+- Loopback no-auth (`web.local_loopback` + peer re-check); non-loopback fail-closed without owner/`--unsafe`.
+- Daemon binary drift in `StatusReport` / schema; web dashboard repair queues exact `hive daemon install --force` under `__hive_host__` allowlist.
+
+See [[commands/setup]], [[commands/web]], [[commands/daemon]].
diff --git a/wiki/log.d/20260715T041711Z-local-web-review-fixes.md b/wiki/log.d/20260715T041711Z-local-web-review-fixes.md
new file mode 100644
index 00000000..0b1d5e5d
--- /dev/null
+++ b/wiki/log.d/20260715T041711Z-local-web-review-fixes.md
@@ -0,0 +1,16 @@
+---
+title: Local web setup review hardening
+date: 2026-07-15
+tags: [web, setup, daemon, release, security, testing]
+---
+
+Hardened the local web install/setup feature after review:
+
+- release archives now contain an exact-version Hive gem source, pass an isolated deployment install, and are checksum-verified with bounded download/extraction resources;
+- managed Rails processes retain their private Bundler environment, and refreshed active services restart plus pass health before setup reports success;
+- diagnostics and daemon-version probes share process-group-aware deadlines, while status verifies PID ownership and reports every failed installed-binary probe as `unreadable`;
+- loopback auth requires a loopback Host as well as peer, and host maintenance is restricted to its sentinel tuple while repair resolves the current binary;
+- setup/init/enrollment preserve a single complete JSON report, the setup/web envelopes now have published schemas, and inactive JSON web status remains a non-zero lifecycle result;
+- CI now runs a live fresh-install with real Bundler, daemon, and Rails processes plus bidirectional TUI/web visibility and automatic dispatch.
+
+See [[commands/setup]], [[commands/web]], [[commands/daemon]], and [[testing]].
diff --git a/wiki/operating.md b/wiki/operating.md
index 2b24d46a..2e18d4ac 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -124,6 +124,24 @@ unit on disk for manual repair. Manual package users should run
 package hooks cannot reliably start a per-user systemd/launchd service for every
 host setup.
 
+### Local web UI (peer to hivebox)
+
+Local (non-Docker) web is a first-class mode sharing the operator's real XDG
+state and enrolled repositories with the TUI/daemon:
+
+```bash
+hive setup                 # diagnose, bootstrap qmd/web bundle, daemon, enroll
+hive setup --service       # also install/start managed hive-web (127.0.0.1:4567)
+hive web                   # foreground Rails (always works without a unit)
+hive web install|start|stop|status
+hive daemon status --json  # includes binary_drift; repair via install --force
+```
+
+Loopback binds skip GitHub login by default (`web.local_loopback: true`);
+non-loopback binds require `web.github.owner` or `--unsafe`. Docker/hivebox
+continues to use `/app/web`, `/data`, and the owner claim flow unchanged.
+See [[commands/setup]] and [[commands/web]].
+
 Updates and uninstall:
 
 ```bash
diff --git a/wiki/testing.md b/wiki/testing.md
index 3ee77fea..9831fa81 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -167,7 +167,7 @@ and only falls back to apt provisioning when missing. If that fallback's
 errors, the workflow disables those Microsoft source files and retries so an
 unrelated third-party apt outage does not hide the verifier's actual behavior.
 
-The browser layer lives in the Rails app: `web/test/integration/*` (device-flow auth via the http DI seam, ownerless first-login claim and later non-owner refusal, plain `/health` versus daemon-backed `/health?deep=1`, ideas with uploads, task Q&A/actions including Advanced Drop, stale-stage 422, red-task Retry recovery queueing, task artifact ordering/markdown rendering/log layout, bounded oversized task diff rendering, media route streaming/refusal plus captured/skipped/failed Demo gallery rendering, repos questionnaire, Repos SSH-origin normalization, non-directory clone-target refusal, Agents-page binary PTY rendering plus operator-ward login polling, favicon/icon serving, Telegram setup guide, and strict blank/@handle chat-ID rejection) and `web/test/system/pipeline_flow_test.rb` (Capybara + Playwright: login gate, composer image attach both paths, Turbo Stream live update, status-grid scroll and composer draft preservation across a live broadcast, Q&A round replacement plus typed-answer survival across morph refreshes, both approve outcomes, log-tail follow/pause/resume, node-preserving log-frame morph reloads, artifact open-state preservation across broadcast-triggered morphs with live content refresh, visible Demo media, and failed-capture banners). CI runs them in the `web` job, installs the root bundle into `vendor/root-bundle`, passes that path as `GOLDEN_E2E_BUNDLE_PATH`, and explicitly runs `web/test/e2e/golden_path_e2e.rb`. The golden-path E2E pins `BUNDLE_GEMFILE`, points `BUNDLE_PATH` at the supplied root bundle, deletes inherited web-bundle deployment/config keys, and preflights the daemon spawn environment with `bundle exec ruby -Ilib bin/hive --version` before starting the foreground daemon, so a broken Bundler/Ruby env fails with the real stderr/stdout instead of a later browser timeout.
+The browser layer lives in the Rails app: `web/test/integration/*` (device-flow auth via the http DI seam, ownerless first-login claim and later non-owner refusal, loopback peer+Host auth bypass checks, plain `/health` versus daemon-backed `/health?deep=1`, ideas with uploads, task Q&A/actions including Advanced Drop, stale-stage 422, red-task Retry recovery queueing, task artifact ordering/markdown rendering/log layout, bounded oversized task diff rendering, media route streaming/refusal plus captured/skipped/failed Demo gallery rendering, repos questionnaire, Repos SSH-origin normalization, non-directory clone-target refusal, Agents-page binary PTY rendering plus operator-ward login polling, favicon/icon serving, Telegram setup guide, and strict blank/@handle chat-ID rejection) and `web/test/system/pipeline_flow_test.rb` (Capybara + Playwright: login gate, composer image attach both paths, Turbo Stream live update, status-grid scroll and composer draft preservation across a live broadcast, Q&A round replacement plus typed-answer survival across morph refreshes, both approve outcomes, log-tail follow/pause/resume, node-preserving log-frame morph reloads, artifact open-state preservation across broadcast-triggered morphs with live content refresh, visible Demo media, and failed-capture banners). CI runs them in the `web` job, installs the root bundle into `vendor/root-bundle`, and runs `test/integration/local_web_setup_e2e_test.rb` with `HIVE_LIVE_LOCAL_WEB_E2E=1`: the test starts with a repo that has no `.hive-state`, uses the real AppBundle/Bundler staging path, boots independent daemon and Rails process groups through one wrapper, waits for deep health, and proves TUI→web and web→TUI visibility plus automatic dispatch. CI then passes the root bundle as `GOLDEN_E2E_BUNDLE_PATH` and runs `web/test/e2e/golden_path_e2e.rb`; its second browser scenario independently repeats the bidirectional parity assertion against a real foreground daemon. The golden-path E2E pins `BUNDLE_GEMFILE`, points `BUNDLE_PATH` at the supplied root bundle, deletes inherited web-bundle deployment/config keys, and preflights the daemon spawn environment with `bundle exec ruby -Ilib bin/hive --version` before starting the foreground daemon, so a broken Bundler/Ruby env fails with the real stderr/stdout instead of a later browser timeout.
 `web/test/test_helper.rb#create_task!` wraps real `Hive::Commands::New`
 task creation. Because generated task slugs use a 16-bit random suffix and
 many web tests intentionally reuse the same task title inside a persistent
