diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml
index 3cbd29bc..d7ff69dd 100644
--- a/.github/workflows/install-smoke.yml
+++ b/.github/workflows/install-smoke.yml
@@ -14,6 +14,7 @@ jobs:
       - uses: actions/checkout@v7
       - run: bash -n install.sh
       - run: bash -n packaging/verify-release.sh
+      - run: bash -n packaging/smoke-local-web.sh
       - name: Shellcheck
         uses: ludeeus/action-shellcheck@00b27aa7cb85167568cb48a3838b75f4265f2bca
         with:
@@ -21,8 +22,32 @@ jobs:
           additional_files: |
             install.sh
             packaging/verify-release.sh
+            packaging/smoke-local-web.sh
       - run: bash install.sh --dry-run --version=v0.1.0
 
+  local-web-artifact:
+    name: installed gem + local web bundle / ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-24.04, macos-15]
+    steps:
+      - uses: actions/checkout@v7
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4"
+          bundler-cache: true
+      - name: Prepare systemd-user acceptance environment
+        if: runner.os == 'Linux'
+        run: |
+          sudo loginctl enable-linger "$USER"
+          sudo systemctl start "user@$(id -u).service"
+          echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV"
+          echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV"
+          systemctl --user daemon-reload
+      - run: packaging/smoke-local-web.sh
+
   macos-dry-run:
     name: macOS dry run
     runs-on: macos-15
@@ -74,6 +99,7 @@ jobs:
       - uses: ruby/setup-ruby@v1
         with:
           ruby-version: "3.4"
+      - uses: sigstore/cosign-installer@v3
       - name: Check pinned release exists
         id: release
         env:
@@ -113,6 +139,7 @@ jobs:
       - uses: ruby/setup-ruby@v1
         with:
           ruby-version: "3.4"
+      - uses: sigstore/cosign-installer@v3
       - name: Check pinned release exists
         id: release
         env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 39fa61fb..53bbbdee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,6 +30,15 @@ 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 version-matched web bundle
+        run: |
+          version="${GITHUB_REF_NAME#v}"
+          staging="$(mktemp -d)"
+          mkdir -p "$staging/web"
+          cp -R web/app web/bin web/config web/db web/public \
+            web/config.ru web/Gemfile web/Gemfile.lock web/Rakefile "$staging/web/"
+          printf '%s\n' "$version" > "$staging/web/.hive-web-version"
+          tar -C "$staging/web" -czf "hive-web-${version}.tar.gz" .
       - 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 +60,11 @@ jobs:
           name: hive-cli-gem
           path: hive-cli-*.gem
           if-no-files-found: error
+      - uses: actions/upload-artifact@v7
+        with:
+          name: hive-web-bundle
+          path: hive-web-*.tar.gz
+          if-no-files-found: error
 
   install-gate:
     name: gem-install gate / ${{ matrix.runs-on }}
@@ -63,31 +77,34 @@ jobs:
       matrix:
         runs-on: [macos-15, ubuntu-24.04-arm]
     steps:
+      - uses: actions/checkout@v7
       - uses: actions/download-artifact@v8
         with:
           name: hive-cli-gem
           path: dist
+      - uses: actions/download-artifact@v8
+        with:
+          name: hive-web-bundle
+          path: dist
       - uses: ruby/setup-ruby@v1
         with:
           ruby-version: "3.4"
-      - name: gem install the built gem
+      - name: Prepare systemd-user acceptance environment
+        if: runner.os == 'Linux'
+        run: |
+          sudo loginctl enable-linger "$USER"
+          sudo systemctl start "user@$(id -u).service"
+          echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV"
+          echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV"
+          systemctl --user daemon-reload
+      - name: Install the gem and boot the matching web bundle
         # Native-runner expansion of build's "Smoke test built gem":
         # confirm the gem installs + runs on macOS arm64 and Linux arm64
         # before publishing. x86_64 is already covered by `build`. The
         # gem is arch=any Ruby, so no Arch/emulated cell here — a flaky
         # emulated cell must never block a release (real Arch install is
         # covered post-release by install-verify.yml).
-        run: |
-          gem_file="$(ls dist/hive-cli-*.gem | head -n 1)"
-          [[ -s "$gem_file" ]] || { echo "no hive-cli-*.gem artifact" >&2; exit 1; }
-          sandbox="$(mktemp -d)/gem-sandbox"
-          mkdir -p "$sandbox"
-          GEM_HOME="$sandbox" GEM_PATH="$sandbox" gem install "$gem_file" \
-            --install-dir "$sandbox" \
-            --bindir "$sandbox/bin" \
-            --no-document \
-            --source https://rubygems.org
-          GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" --version
+        run: packaging/smoke-local-web.sh dist/hive-cli-*.gem dist/hive-web-*.tar.gz
 
   release-finalize:
     name: Publish GitHub release
@@ -116,10 +133,14 @@ jobs:
         with:
           name: hive-cli-gem
           path: dist
+      - uses: actions/download-artifact@v8
+        with:
+          name: hive-web-bundle
+          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/Gemfile.lock b/Gemfile.lock
index 571abed1..01c36de0 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.4)
       sqlite3 (~> 2.0)
       telegram-bot-ruby (~> 2.7)
       thor (~> 1.3)
@@ -113,6 +114,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)
diff --git a/README.md b/README.md
index 2fca3136..832c6475 100644
--- a/README.md
+++ b/README.md
@@ -45,9 +45,25 @@ Hive ships as a rubygem (`hive-cli`) attached to each GitHub Release, signed wit
 | Ubuntu 22.04+ / glibc Linux x86_64/aarch64 | <code>tmpdir="$(mktemp -d)" && trap 'rm -rf "$tmpdir"' EXIT && curl -fsSL https://raw.githubusercontent.com/ivankuznetsov/hive/v0.3.2/install.sh -o "$tmpdir/hive-install.sh" && bash "$tmpdir/hive-install.sh"</code> |
 | Arch Linux x86_64/aarch64 | [`yay -S hive-bin`](https://aur.archlinux.org/packages/hive-bin) |
 
-Prerequisites: **Ruby 3.4** (the gem and its runtime deps install against this), git ≥ 2.40, authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, authenticated `gh`, `tmux` ≥ 3.0 when the project uses the default `claude.mode: tmux`, and Node.js/npm for managed QMD install/repair. The bash installer reports its own installer-side prereqs (`curl`, `jq`, `gem`, checksum tool) on first run; if npm is missing, Hive still installs and `hive doctor` reports the QMD gap non-fatally.
+Prerequisites: **Ruby 3.4** (the gem and its runtime deps install against this), git ≥ 2.40, authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, authenticated `gh`, `cosign` for release authentication, `tmux` ≥ 3.0 when the project uses the default `claude.mode: tmux`, and Node.js/npm for managed QMD install/repair. The bash installer reports its own installer-side prereqs (`curl`, `jq`, `cosign`, `gem`, checksum tool) on first run; if npm is missing, Hive still installs and `hive doctor` reports the QMD gap non-fatally.
 
-The vendored gems land under `${XDG_DATA_HOME:-~/.local/share}/hive/gems/` so the install is self-contained and uninstall is a clean `rm -rf`. Full install matrix, XDG paths, Apache Hive collision behavior (`hv` shim), update, uninstall, and autostart details live in [wiki/operating.md#install](wiki/operating.md#install) and [wiki/operating.md#autostart](wiki/operating.md#autostart).
+The vendored gems land under `${XDG_DATA_HOME:-~/.local/share}/hive/gems/`. After installing the CLI, the supported local quick start is:
+
+```bash
+cd ~/Dev/your-project
+hive setup
+```
+
+On Linux or macOS this diagnoses every prerequisite, bootstraps Hive-owned QMD
+and the version-matched Rails bundle, enrolls the checkout, and installs the
+daemon and web as separate per-user services. The UI becomes ready at
+<http://127.0.0.1:4567>. Use `hive setup --no-service` when you want to run
+`hive web` in the foreground instead. Docker/hivebox remains available as an
+isolated alternative; local mode uses the same real XDG config and project
+state as the CLI, TUI, and daemon.
+
+Full install matrix, XDG paths, web lifecycle, security, update, uninstall, and
+autostart details live in [wiki/operating.md](wiki/operating.md).
 
 ### From a development clone
 
diff --git a/docs/architecture.md b/docs/architecture.md
index 2213d857..38e109a5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -24,6 +24,11 @@ Hive is a Ruby CLI around filesystem state, agent subprocesses, and git worktree
 
 The project checkout holds code. `.hive-state/` holds durable Hive state on the separate `hive/state` branch. The feature worktree holds code changes for one task branch.
 
+The local Rails UI is another reader/writer of those same trees. It does not
+create a `/data` sandbox or a second task database; `/data` remains specific to
+Docker/hivebox. The daemon and web server are deliberately separate processes
+and per-user services.
+
 ## Storage Layout
 
 `hive init .` creates the per-project storage tree:
@@ -47,6 +52,18 @@ The project checkout holds code. `.hive-state/` holds durable Hive state on the
 
 Each stage has one state file: `idea.md` (1-inbox), `brainstorm.md` (2-brainstorm), `plan.md` (3-plan), `task.md` (shared across 4-execute, 6-review, 9-done), `artifact.md` (7-artifacts), `pr.md` (shared across 5-open-pr and 8-finalize), or `summary.md` (8-finalize). `worktree.yml` points from Hive state to the feature worktree created during execute. The xbookmark walkthrough captures a mid-run tree in [docs/assets/xbookmark-state-tree.txt](assets/xbookmark-state-tree.txt).
 
+Managed local-web files are split by mutability:
+
+```text
+${XDG_DATA_HOME:-~/.local/share}/hive/web/          # active Rails bundle
+${XDG_DATA_HOME:-~/.local/share}/hive/web-gems/     # isolated Bundler path
+${XDG_STATE_HOME:-~/.local/state}/hive/web-storage/ # SQLite and Rails state
+```
+
+The release bundle is version-matched to the installed gem, checksum/signature
+verified, validated before extraction, prepared in staging, and atomically
+activated. Uninstall preserves `web-storage` unless state purge is explicit.
+
 ## Agents
 
 Hive has built-in agent profiles for `claude`, `codex`, and `pi`. A profile defines the binary, version check, prompt flag, add-dir behavior, skill invocation syntax, and status-detection mode. Stage runners look up the configured profile before spawning the subprocess.
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
new file mode 100644
index 00000000..571d133f
--- /dev/null
+++ b/docs/cli-reference.md
@@ -0,0 +1,57 @@
+# Local Setup and Web CLI
+
+The complete command tree remains available through `hive help`. These are the
+first-class local web and setup commands.
+
+## `hive setup`
+
+```text
+hive setup [--no-bootstrap] [--no-init] [--no-service] [--json]
+```
+
+Runs diagnostics, bootstraps Hive-owned QMD and the matching Rails bundle,
+installs/starts the daemon with the currently invoked Hive binary, enrolls the
+current repository, installs/starts the separate web service, and waits for
+HTTP readiness. Mandatory external dependency failures stop before service
+mutation. `--no-bootstrap` diagnoses only, `--no-init` skips repository
+enrollment, and `--no-service` leaves web execution to foreground `hive web`.
+
+## `hive web`
+
+```text
+hive web [--bind ADDRESS] [--port PORT] [--unsafe]
+hive web install [--force] [--json]
+hive web start [--json]
+hive web stop [--json]
+hive web status [--json]
+```
+
+Bare `hive web` replaces the CLI process with a foreground Rails server.
+Managed lifecycle commands operate the independent systemd-user or launchd
+service. The default URL is `http://127.0.0.1:4567`. A non-loopback bind
+requires a configured GitHub device-flow client (including a fresh claimable
+installation) or the explicit `--unsafe` override. Bind, port, and unsafe
+overrides supplied to lifecycle commands are persisted in the installed unit.
+
+Default release bundles are authenticated through the release's cosign-signed
+`SHA256SUMS`; missing cosign is a hard failure. A private/custom bundle may be
+selected only as a URL/digest pair:
+
+```text
+HIVE_WEB_BUNDLE_URL=https://packages.example/hive-web.tar.gz
+HIVE_WEB_BUNDLE_SHA256=<64 hexadecimal characters>
+```
+
+## Daemon drift
+
+`hive daemon status --json` reports process state, service state, installed and
+current executable/version, readiness, the last maintenance result, and a
+closed drift reason. `hive daemon repair [--json]` and
+`hive daemon restart [--json]` are the same fixed operations exposed by the
+local UI. Both refuse when agent tasks are active.
+
+## Uninstall
+
+`hive uninstall` stops and unregisters both user services and removes the
+managed web bundle/dependencies. Mutable Rails SQLite state is retained by
+default. `hive uninstall --force-purge-state` removes it as well.
diff --git a/docs/faq.md b/docs/faq.md
index a0971ceb..3cc35fe2 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -22,9 +22,13 @@ Hive state changes often and should not pollute the project's code history or tr
 
 The daemon service is installed as global user infrastructure so it survives login and reboot. Project enrollment stays explicit because the daemon can spend real agent time and move many tasks; `daemon.enabled: true` is the durable consent signal for a specific repository, and `--dry-run` lets you inspect dispatches before live mode.
 
-### Why no built-in web UI?
+### Does Hive have a built-in web UI?
 
-The core interface is the filesystem and CLI. A web UI would add another state surface before the file protocol is finished.
+Yes. `hive setup` installs the version-matched Rails UI and exposes it on
+`127.0.0.1:4567`; `hive web` runs it in the foreground. The UI is not another
+state surface: it reads and writes the same XDG configuration and checked-out
+project `.hive-state/` trees as the CLI, TUI, and daemon. Docker/hivebox remains
+the isolated deployment alternative.
 
 ### Why more than one agent?
 
@@ -32,6 +36,24 @@ Hive treats agent CLIs as profiles. Planning, implementation, review, and browse
 
 ## Troubleshooting
 
+### `hive setup` reports a missing or unauthenticated dependency
+
+Setup only bootstraps Hive-owned QMD and the Rails bundle. Run the exact
+remediation it prints for Ruby 3.4, git, tmux, Node/npm, SQLite, `gh`, Claude,
+or Codex, then rerun `hive setup`. `--no-bootstrap` diagnoses without writes.
+
+### `hive web` reports a port conflict
+
+Another process owns the configured bind/port. Stop it or choose another port
+with `hive web --port <port>`. Managed status separates process, port, and HTTP
+readiness failures: `hive web status --json`.
+
+### Local web refuses a non-loopback bind
+
+Configure the existing owner/auth flow, or deliberately pass `--unsafe` and
+heed the persistent warning. Loopback login bypass requires both a loopback
+bind and an actual loopback peer; forwarded headers cannot grant it.
+
 ### `already initialized`
 
 Cause: the project already has a `hive/state` branch. Fix: skip `hive init` or use the existing `.hive-state/`. Exit code: 2.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 104028df..a980be33 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -18,17 +18,22 @@ If `~/.local/bin` is not on your `PATH`, put the symlink in a directory that is
 
 ```bash
 hive --version
-hive daemon install
 ```
 
 ## Step 2 - Attach Hive To A Project
 
 ```bash
 cd ~/Dev/xbookmark
-hive init .
+hive setup
 ```
 
-`hive init` creates `.hive-state/` as a worktree of the orphan `hive/state` branch, registers the project in `~/Dev/hive/config.yml`, and scaffolds the stage folders. Read the storage details in [docs/architecture.md#storage-layout](architecture.md#storage-layout).
+`hive setup` checks prerequisites, creates `.hive-state/` as a worktree of the
+orphan `hive/state` branch, registers the project, installs the daemon and
+local web as separate user services, and waits for
+<http://127.0.0.1:4567/health>. It is safe to rerun. Use `--no-service` to
+prepare everything without installing the web service, then start a foreground
+server with `hive web`. Read the storage details in
+[docs/architecture.md#storage-layout](architecture.md#storage-layout).
 
 ## Step 3 - Capture The Idea
 
diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist
new file mode 100644
index 00000000..3070d7e3
--- /dev/null
+++ b/examples/launchd/hive-web.plist
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//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>
+  <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>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/Users/YOU/.local/share/mise/shims:/Users/YOU/.rbenv/shims:/Users/YOU/.asdf/shims:/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+  </dict>
+  <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.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/YOU/Library/Logs/hive-web.log</string>
+</dict>
+</plist>
diff --git a/examples/systemd/hive-web.service b/examples/systemd/hive-web.service
new file mode 100644
index 00000000..812d72c7
--- /dev/null
+++ b/examples/systemd/hive-web.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Hive local web UI
+After=network.target hive-daemon.service
+StartLimitBurst=3
+StartLimitIntervalSec=300
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/env hive web
+Restart=on-failure
+RestartSec=10
+Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
+
+[Install]
+WantedBy=default.target
diff --git a/hive.gemspec b/hive.gemspec
index e54a3dec..907b9dcd 100644
--- a/hive.gemspec
+++ b/hive.gemspec
@@ -40,6 +40,7 @@ Gem::Specification.new do |spec|
     "schemas/**/*.json",
     "examples/systemd/*",
     "examples/launchd/*",
+    "hive.gemspec",
     "install.md",
     "CHANGELOG.md",
     "LICENSE",
@@ -60,6 +61,7 @@ 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"
+  spec.add_dependency "rexml", "~> 3.4"
   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..927e3f26 100644
--- a/install.md
+++ b/install.md
@@ -4,7 +4,12 @@ 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, verify `hive --version`, then run
+`hive setup` from the repository the user wants to enroll. Setup diagnoses
+external prerequisites, installs Hive-owned QMD and the matching signed Rails
+bundle, installs the separate daemon and web per-user services, and waits for
+<http://127.0.0.1:4567/health>. Do not auto-install or authenticate external
+tools such as Ruby, git, tmux, Node.js/npm, SQLite, `gh`, Claude, or Codex.
 
 ## Detect
 
@@ -111,11 +116,14 @@ else
   exit 1
 fi
 "$hive_cmd" --version
+"$hive_cmd" setup
+"$hive_cmd" daemon status --json
+"$hive_cmd" web status --json
 ```
 
 If `hive` is shadowed by Apache Hive, try `hv --version` and tell the user to use `hv` or adjust PATH.
 
-## Daemon Autostart
+## Service Autostart
 
 Do not ask the user whether to initialize the daemon. Hive install includes the per-user daemon service by default. After version verification, run this once for every channel and report the outcome:
 
@@ -125,6 +133,11 @@ Do not ask the user whether to initialize the daemon. Hive install includes the
 
 The bash installer already runs the same command after installing the gem; rerunning it is idempotent when the unit matches. If the command reports a drifted/customized unit, leave it untouched and report the `"$hive_cmd" daemon install --force` recovery command instead of forcing an overwrite. If systemd-user or launchd is unavailable, keep Hive installed and report that daemon autostart could not be enabled on this host.
 
+`hive setup` also installs the independent `hive-web` user service. Its
+lifecycle is `hive web install|start|stop|status`; bare `hive web` is always a
+foreground server. Use `hive setup --no-service` on hosts without a usable
+per-user service manager.
+
 ## Initialize Project
 
 If the current directory is a git project and the user wants Hive enabled here, ask before running:
@@ -156,6 +169,7 @@ Report:
 - command run
 - Hive CLI version output (`"$hive_cmd" --version`)
 - daemon autostart setup result from `"$hive_cmd" daemon install --json`
+- web readiness result from `"$hive_cmd" web status --json`
 - whether `hive init` was run
 - missing runtime dependencies from `hive doctor`
 - `qmd --version` output, or the reason QMD install/repair was skipped
diff --git a/install.sh b/install.sh
index bd8f6e2d..fcc622e4 100755
--- a/install.sh
+++ b/install.sh
@@ -15,7 +15,7 @@ usage: install.sh [--dry-run] [--prefix=<dir>] [--version=<tag>]
 
 Installs hive as a rubygem (\`hive-cli\`) from GitHub Releases. The .gem
 is signed with cosign keyless attestation against this repo's release
-workflow; verification fails closed when cosign is available.
+workflow; cosign is required and verification fails closed.
 
 After install the \`hive\` and \`hv\` executables are symlinked into
 \${XDG_BIN_HOME:-~/.local/bin}. The installer also runs \`hive daemon install\`
@@ -25,7 +25,7 @@ telegram-bot-ruby) live under \${HIVE_PREFIX:-~/.local/share}/hive/gems so an
 uninstall is a clean \`rm -rf\` plus symlink removal.
 
 Requires Ruby 3.4 already on PATH; the installer reports its own
-prereqs (\`curl\`, \`jq\`, checksum tool) on first run. When npm is
+prereqs (\`curl\`, \`jq\`, \`cosign\`, checksum tool) on first run. When npm is
 available, the installer also installs Hive's qmd wiki indexer into the
 Hive data directory and links it beside the \`hive\` executable. Set
 HIVE_INSTALL_QMD=0 to skip that step.
@@ -177,6 +177,10 @@ sha256_cmd() {
 
 install_hint() {
   local dep="$1"
+  if [[ "$dep" == "cosign" ]]; then
+    printf 'follow https://docs.sigstore.dev/cosign/system_config/installation/'
+    return
+  fi
   case "$(uname -s)" in
     Darwin) printf 'brew install %s' "$dep" ;;
     Linux)
@@ -195,7 +199,11 @@ install_hint() {
 # crashing mid-curl / mid-gem-install with a confusing trace.
 installer_preflight() {
   local dep missing=0
-  for dep in curl jq; do
+  local -a dependencies=(curl jq)
+  if [[ "$DRY_RUN" -ne 1 ]]; then
+    dependencies+=(cosign)
+  fi
+  for dep in "${dependencies[@]}"; do
     if ! command -v "$dep" >/dev/null 2>&1; then
       warn "missing installer prerequisite '${dep}' ($(install_hint "$dep"))"
       missing=1
@@ -429,33 +437,21 @@ trap 'rm -rf "$tmpdir"' EXIT
 download_with_status "$gem_url" "${tmpdir}/${gem_file}" "release gem"
 download_with_status "$checksums_url" "${tmpdir}/SHA256SUMS" "SHA256SUMS"
 
-# Cosign verify SHA256SUMS when both the signature blob and the
-# keyless cert are published — release.yml writes both. We skip the
-# verification only when cosign isn't installed; missing signature
-# files are an attestable regression and we fail closed.
-if command -v cosign >/dev/null 2>&1; then
-  download_with_status "$sig_url" "${tmpdir}/SHA256SUMS.sig" "SHA256SUMS.sig"
-  download_with_status "$cert_url" "${tmpdir}/SHA256SUMS.pem" "SHA256SUMS.pem"
-  # Pin the keyless identity to OUR release workflow rather than `.*`.
-  # `.*` regexps would accept any GHA OIDC token from any repo —
-  # neutering the signature check. The identity must match the
-  # release.yml workflow path under ivankuznetsov/hive (allowing
-  # forks under HIVE_REPO_OWNER/HIVE_REPO_NAME via env). The issuer
-  # is GitHub Actions' Fulcio OIDC endpoint.
-  cosign verify-blob \
-    --certificate "${tmpdir}/SHA256SUMS.pem" \
-    --signature "${tmpdir}/SHA256SUMS.sig" \
-    --certificate-identity-regexp "^https://github\\.com/${REPO_OWNER}/${REPO_NAME}/" \
-    --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
-    "${tmpdir}/SHA256SUMS" \
-    || die "cosign verify-blob failed for SHA256SUMS (identity must match ${REPO_OWNER}/${REPO_NAME} release workflow)"
-else
-  # Phrased as a positive info line, not a scare-warning: the SHA256 check still
-  # runs (see below). cosign is an optional second factor for keyless signature
-  # verification — installing it upgrades the check; not having it doesn't break
-  # the install.
-  log "verifying release with SHA256 (install cosign for additional keyless signature verification)"
-fi
+# The checksum manifest and archive share one download channel, so the digest
+# alone is not an authenticity boundary. Require cosign and pin the exact
+# release workflow identity/tag before trusting any manifest entry.
+command -v cosign >/dev/null 2>&1 ||
+  die "missing prerequisite: cosign (install from https://docs.sigstore.dev/cosign/system_config/installation/)"
+download_with_status "$sig_url" "${tmpdir}/SHA256SUMS.sig" "SHA256SUMS.sig"
+download_with_status "$cert_url" "${tmpdir}/SHA256SUMS.pem" "SHA256SUMS.pem"
+cosign verify-blob \
+  --certificate "${tmpdir}/SHA256SUMS.pem" \
+  --signature "${tmpdir}/SHA256SUMS.sig" \
+  --certificate-identity \
+    "https://github.com/${REPO_OWNER}/${REPO_NAME}/.github/workflows/release.yml@refs/tags/${version}" \
+  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
+  "${tmpdir}/SHA256SUMS" \
+  || die "cosign verify-blob failed for SHA256SUMS (identity must match ${REPO_OWNER}/${REPO_NAME} release workflow)"
 
 # Strict line match: optional `./` prefix, sha digest, two-space sep,
 # exact gem_file, optional CR. `|| true` so a no-match under `set -e`
diff --git a/lib/hive.rb b/lib/hive.rb
index b22bfe0e..3c17deac 100644
--- a/lib/hive.rb
+++ b/lib/hive.rb
@@ -30,6 +30,7 @@ module Hive
       "hive-daemon-enroll" => 1,
       "hive-daemon-reload" => 1,
       "hive-daemon-install" => 1,
+      "hive-daemon-maintenance" => 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`.
@@ -57,6 +58,9 @@ module Hive
       "hive-bot-stop" => 1,
       "hive-bot-reload" => 1,
       "hive-bot-install" => 1,
+      "hive-setup" => 1,
+      "hive-web-install" => 1,
+      "hive-web-status" => 1,
       # File-backed dispatch request the bot writes for the daemon to
       # consume. One JSON file per pending request under the state-home
       # `dispatch_requests/` directory. See
diff --git a/lib/hive/agent_profile.rb b/lib/hive/agent_profile.rb
index 770f4b9e..8ee43b81 100644
--- a/lib/hive/agent_profile.rb
+++ b/lib/hive/agent_profile.rb
@@ -1,5 +1,6 @@
 require "open3"
 require "timeout"
+require "hive/bounded_process"
 
 module Hive
   # Per-CLI invocation contract for a headless agent.
@@ -235,9 +236,9 @@ module Hive
       # credentials or hang on first run. Without this, spawn_agent's
       # preflight could block indefinitely outside the per-stage timeout.
       begin
-        out, _err, status = Timeout.timeout(VERSION_CHECK_TIMEOUT_SEC) do
-          Open3.capture3(bin, @version_flag)
-        end
+        out, _err, status = Hive::BoundedProcess.capture3(
+          bin, @version_flag, timeout: VERSION_CHECK_TIMEOUT_SEC
+        )
       rescue Errno::ENOENT, Errno::EACCES => e
         raise Hive::AgentError, "#{@name} binary not runnable: #{bin} (#{e.class.name.split('::').last}: #{e.message})"
       rescue Timeout::Error
diff --git a/lib/hive/bounded_process.rb b/lib/hive/bounded_process.rb
new file mode 100644
index 00000000..6b5f5fbf
--- /dev/null
+++ b/lib/hive/bounded_process.rb
@@ -0,0 +1,102 @@
+require "open3"
+require "timeout"
+
+module Hive
+  module BoundedProcess
+    POLL_INTERVAL = 0.05
+    TERMINATE_GRACE = 0.2
+
+    module_function
+
+    def capture3(*argv, env: {}, chdir: nil, timeout:)
+      options = { pgroup: true }
+      options[:chdir] = chdir if chdir
+      started = monotonic
+
+      Open3.popen3(env, *argv, **options) do |stdin, stdout, stderr, wait_thread|
+        stdin.close
+        stdout_reader = Thread.new { read_stream(stdout) }
+        stderr_reader = Thread.new { read_stream(stderr) }
+
+        wait_for(wait_thread, timeout, started)
+        [ stdout_reader.value, stderr_reader.value, wait_thread.value ]
+      rescue Timeout::Error
+        terminate_group(wait_thread.pid, wait_thread: wait_thread)
+        stdout_reader&.kill
+        stderr_reader&.kill
+        raise
+      end
+    end
+
+    def run(env, *argv, chdir:, timeout:, out: $stdout, err: $stderr)
+      pid = Process.spawn(env, *argv, chdir: chdir, pgroup: true, out: out, err: err)
+      started = monotonic
+      loop do
+        waited = Process.waitpid2(pid, Process::WNOHANG)
+        return waited.last.success? if waited
+
+        raise Timeout::Error, "subprocess timed out after #{timeout}s" if monotonic - started >= timeout
+
+        sleep POLL_INTERVAL
+      end
+    rescue Timeout::Error
+      terminate_group(pid)
+      raise
+    end
+
+    def wait_for(wait_thread, timeout, started)
+      loop do
+        return if wait_thread.join(POLL_INTERVAL)
+        raise Timeout::Error, "subprocess timed out after #{timeout}s" if monotonic - started >= timeout
+      end
+    end
+    private_class_method :wait_for
+
+    def terminate_group(pid, wait_thread: nil)
+      return unless pid
+
+      signal_group("TERM", pid)
+      deadline = monotonic + TERMINATE_GRACE
+      while monotonic < deadline
+        if wait_thread
+          return if wait_thread.join(POLL_INTERVAL)
+        else
+          return if process_gone?(pid)
+          sleep POLL_INTERVAL
+        end
+
+      end
+      signal_group("KILL", pid)
+      wait_thread ? wait_thread.join : Process.waitpid(pid)
+    rescue Errno::ECHILD, Errno::ESRCH
+      nil
+    end
+    private_class_method :terminate_group
+
+    def signal_group(signal, pid)
+      Process.kill(signal, -pid)
+    rescue Errno::ESRCH
+      nil
+    end
+    private_class_method :signal_group
+
+    def process_gone?(pid)
+      Process.waitpid(pid, Process::WNOHANG)
+    rescue Errno::ECHILD
+      true
+    end
+    private_class_method :process_gone?
+
+    def read_stream(stream)
+      stream.read
+    rescue IOError
+      ""
+    end
+    private_class_method :read_stream
+
+    def monotonic
+      Process.clock_gettime(Process::CLOCK_MONOTONIC)
+    end
+    private_class_method :monotonic
+  end
+end
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index cd809068..05969fc1 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -50,6 +50,25 @@ module Hive
     end
     map "--version" => :version
 
+    desc "setup", "Diagnose and provision local Hive daemon and web services"
+    option :no_bootstrap, type: :boolean, default: false,
+                          desc: "diagnose Hive-owned dependencies without installing them"
+    option :no_init, type: :boolean, default: false,
+                     desc: "do not initialize or enroll the current repository"
+    option :no_service, type: :boolean, default: false,
+                        desc: "prepare web dependencies but leave web execution foreground-only"
+    def setup
+      require "hive/commands/setup"
+      result = Hive::Commands::Setup.new(
+        project_root: Dir.pwd,
+        no_bootstrap: options[:no_bootstrap],
+        no_init: options[:no_init],
+        no_service: options[:no_service],
+        json: options[:json]
+      ).call
+      exit 1 unless result["ok"]
+    end
+
     desc "init [PROJECT_PATH]", "Bootstrap .hive-state (orphan hive/state branch); TTY-prompts for agents + limits"
     long_desc <<~DESC
       Initialises hive in PROJECT_PATH (defaults to the current directory):
@@ -337,10 +356,11 @@ module Hive
                                desc: "also remove XDG state and registered project .hive-state directories"
     def uninstall
       require "hive/commands/uninstall"
-      Hive::Commands::Uninstall.new(
+      result = Hive::Commands::Uninstall.new(
         purge: options[:purge],
         force_purge_state: options[:force_purge_state]
       ).call
+      exit result unless result.zero?
     end
 
     desc "migrate [PROJECT_PATH]", "Rename in-flight task folders from the pre-open-pr stage layout"
@@ -1037,7 +1057,7 @@ module Hive
       ).call
     end
 
-    desc "daemon SUBCOMMAND [PROJECT]", "Manage the hive daemon (start / stop / status / reload / tail / install / enable / disable / queue)"
+    desc "daemon SUBCOMMAND [PROJECT]", "Manage the hive daemon (start / stop / status / repair / restart / install / enable / disable / queue)"
     long_desc <<~DESC
       Subcommands:
         start [--detach] [--dry-run]      Run the dispatcher loop. Without
@@ -1045,6 +1065,11 @@ module Hive
         stop [--json]                     Send SIGTERM to the running daemon.
                                           --json emits hive-daemon-stop.v1.
         status [--json]                   Show running / not-running.
+        repair [--json]                   Safely reinstall the service using
+                                          this Hive binary. Refuses while
+                                          agent tasks are active.
+        restart [--json]                  Safely restart the service. Refuses
+                                          while agent tasks are active.
         reload [--json]                   Send SIGHUP to reload config.
                                           --json emits hive-daemon-reload.v1.
         tail                              Stream daemon.log.
@@ -1332,11 +1357,15 @@ module Hive
       ).call
     end
 
-    desc "web", "Run the hivebox web UI"
+    desc "web [SUBCOMMAND]", "Run local web or manage its service (install / start / stop / status)"
     option :bind, type: :string, desc: "override web.bind"
     option :port, type: :numeric, desc: "override web.port"
-    def web
-      if options[:json]
+    option :unsafe, type: :boolean, default: false,
+                    desc: "permit a non-loopback bind without configured owner/auth"
+    option :force, type: :boolean, default: false,
+                   desc: "for install: replace a drifted service definition after backup"
+    def web(subcommand = nil)
+      if options[:json] && subcommand.nil?
         require "json"
         message = "hive web has no JSON output (it runs a long-lived server). " \
                   "Use 'hive status --json' for machine-readable task data."
@@ -1355,7 +1384,11 @@ module Hive
       end
 
       require "hive/commands/web"
-      Hive::Commands::Web.new(bind: options[:bind], port: options[:port]).call
+      Hive::Commands::Web.new(
+        subcommand,
+        bind: options[:bind], port: options[:port], unsafe: options[:unsafe],
+        force: options[:force], json: options[:json]
+      ).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..66e7ce93 100644
--- a/lib/hive/commands/daemon.rb
+++ b/lib/hive/commands/daemon.rb
@@ -37,7 +37,7 @@ module Hive
       include Hive::Schemas::EnvelopeEmitter
       include Hive::PidFile
 
-      VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable install queue].freeze
+      VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable install repair restart queue].freeze
 
       # Actions for `hive daemon queue ACTION` (AN-1/2/3). `list` is the
       # default when no action is given.
@@ -86,6 +86,7 @@ module Hive
         when "reload"           then reload_daemon
         when "tail"             then tail_daemon
         when "install"          then install_daemon
+        when "repair", "restart" then maintain_daemon
         when "queue"            then queue_command
         when "enable", "disable" then call_with_envelope { do_call }
         end
@@ -355,46 +356,33 @@ module Hive
       end
 
       def status_daemon
-        running = false
-        pid = nil
-        uptime_sec = nil
-
-        if File.exist?(pid_file)
-          payload = read_pid_file_payload
-          pid = payload && payload["pid"]
-          if pid && pid > 0 && pid_alive?(pid) && pid_owned_by_us?(payload, pid)
-            running = true
-            stat = File.stat(pid_file)
-            uptime_sec = (Time.now - stat.mtime).to_i
-          end
-        end
+        require "hive/daemon/status_report"
+        require "hive/commands/daemon/service_installer"
+        report = Hive::Daemon::StatusReport.new(
+          installer: Hive::Commands::Daemon::ServiceInstaller.new,
+          pid_probe: self,
+          pid_file: pid_file,
+          log_file: log_file
+        ).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"],
+            **report,
             # 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
           )
-        elsif running
-          puts "hive daemon: running (pid #{pid}, uptime #{uptime_sec}s)"
+        elsif report["running"]
+          puts "hive daemon: running (pid #{report['pid']}, uptime #{report['uptime_sec']}s)"
+          puts "hive daemon: service drift=#{report['drift']}" unless report["drift"] == "none"
         else
-          puts "hive daemon: not running"
+          puts "hive daemon: not running (service drift=#{report['drift']})"
         end
         # Exit code: 0 for running, 1 for not running (per plan U8)
-        raise Hive::Error, "daemon not running" unless running
+        raise Hive::Error, "daemon not running" unless report["running"]
       end
 
       # Read-only autostart-state snapshot for the status envelope. A status
@@ -561,6 +549,33 @@ module Hive
         emit_install_outcome(installer, result)
       end
 
+      # Fixed, machine-readable maintenance primitives shared with the web
+      # status page. Both actions fail closed when status cannot prove that
+      # no agents are active, so a unit repair/restart cannot silently tear
+      # down in-flight work.
+      def maintain_daemon
+        require "hive/commands/daemon/service_installer"
+        require "hive/web/daemon_maintenance"
+        installer = Hive::Commands::Daemon::ServiceInstaller.new(
+          binary_path: current_binary_path
+        )
+        result = Hive::Web::DaemonMaintenance.new(installer: installer).call(@subcommand)
+        payload = {
+          "schema" => "hive-daemon-maintenance",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-maintenance")
+        }.merge(result)
+        if @json
+          puts JSON.generate(payload)
+        elsif result["ok"]
+          puts "hive daemon: #{result['message']}"
+        else
+          warn "hive daemon: #{result['message']}"
+        end
+        raise Hive::Error, result["message"] unless result["ok"]
+
+        payload
+      end
+
       # Bare-text positive confirmation on the non-JSON success path
       # so operators can distinguish first-time install / no-op /
       # in-place upgrade at a glance. Mirrors what `reload_daemon`
@@ -671,7 +686,7 @@ module Hive
       end
 
       def current_binary_path
-        Hive::InvokedBinary.path
+        Hive::InvokedBinary.path || File.expand_path("../../../bin/hive", __dir__)
       end
 
       def safe_install_platform(installer)
diff --git a/lib/hive/commands/doctor.rb b/lib/hive/commands/doctor.rb
index 99afbc08..46f0c4ec 100644
--- a/lib/hive/commands/doctor.rb
+++ b/lib/hive/commands/doctor.rb
@@ -3,6 +3,7 @@ require "open3"
 require "timeout"
 
 require "hive"
+require "hive/bounded_process"
 require "hive/config"
 require "hive/agent_profiles"
 require "hive/agent_profiles/claude"
@@ -119,7 +120,7 @@ module Hive
         end
 
         begin
-          out, err, status = Timeout.timeout(15) { Open3.capture3(qmd, "--version") }
+          out, err, status = Hive::BoundedProcess.capture3(qmd, "--version", timeout: 15)
         rescue Timeout::Error, SystemCallError => e
           return [ warning_row(
             stage: "wiki",
diff --git a/lib/hive/commands/service_installer/base.rb b/lib/hive/commands/service_installer/base.rb
index 04f9ba76..7fbfcbba 100644
--- a/lib/hive/commands/service_installer/base.rb
+++ b/lib/hive/commands/service_installer/base.rb
@@ -2,6 +2,7 @@ require "cgi"
 require "fileutils"
 require "hive/install_channel"
 require "hive/commands/service_installer/outcome"
+require "hive/bounded_process"
 require "rbconfig"
 require "shellwords"
 
@@ -19,7 +20,7 @@ module Hive
         attr_reader :messages
 
         def initialize(host_os: RbConfig::CONFIG["host_os"], home: nil, binary_path: nil, runner: nil,
-                       systemctl_available: nil, launchctl_available: nil)
+                       systemctl_available: nil, launchctl_available: nil, capture_runner: nil)
           @host_os = host_os
           # Anchor on the real user home for launchd/systemd paths —
           # HIVE_HOME is a config/test override that does not apply
@@ -29,6 +30,9 @@ module Hive
           @home = File.expand_path(home || ENV["HOME"] || Dir.home)
           @binary_path = binary_path
           @runner = runner || ->(argv) { system(*argv, out: File::NULL) }
+          @capture_runner = capture_runner || lambda { |argv|
+            Hive::BoundedProcess.capture3(*argv, timeout: 3)
+          }
           @systemctl_available = systemctl_available
           @launchctl_available = launchctl_available
           @messages = []
@@ -87,6 +91,66 @@ module Hive
           "local.#{service_name}"
         end
 
+        def start!
+          case platform
+          when :linux
+            return false unless systemctl_available?
+            @runner.call([ "systemctl", "--user", "start", service_name ])
+          when :macos
+            return false unless launchctl_available?
+            @runner.call([ "launchctl", "start", launchd_label ])
+          else
+            false
+          end
+        end
+
+        def stop!
+          case platform
+          when :linux
+            return true unless systemctl_available?
+            @runner.call([ "systemctl", "--user", "stop", service_name ])
+          when :macos
+            return true unless launchctl_available?
+            @runner.call([ "launchctl", "stop", launchd_label ])
+          else
+            true
+          end
+        end
+
+        def running?
+          case platform
+          when :linux
+            systemctl_available? &&
+              !!@runner.call([ "systemctl", "--user", "is-active", "--quiet", service_name ])
+          when :macos
+            return false unless launchctl_available?
+
+            stdout, _stderr, status = @capture_runner.call([ "launchctl", "list", launchd_label ])
+            return false unless status.success?
+
+            text = stdout.to_s
+            table_pid = text.lines.first.to_s.strip.split(/\s+/).first
+            table_pid.match?(/\A\d+\z/) || text.match?(/["']?PID["']?\s*=\s*\d+/)
+          else
+            false
+          end
+        rescue SystemCallError, Timeout::Error
+          false
+        end
+
+        def service_manager_available?
+          case platform
+          when :linux then !!systemctl_available?
+          when :macos then !!launchctl_available?
+          else false
+          end
+        end
+
+        def unit_readable?
+          path = target_path
+          !path.nil? && File.file?(path) && File.readable?(path)
+        end
+
         # ── Subclass hooks ─────────────────────────────────────────────
         # Subclasses MUST override these. The base raises so a missing
         # override fails loudly rather than rendering a half-built unit.
@@ -293,6 +357,12 @@ module Hive
           "Environment=PATH=#{base.join(':')}"
         end
 
+        def launchd_path
+          build_path_line.delete_prefix("Environment=PATH=")
+                         .gsub("%h", @home)
+                         .sub(":/usr/local/bin", ":/opt/homebrew/bin:/usr/local/bin")
+        end
+
         def ruby_shim_dir
           ruby_path = which("ruby")
           return nil unless ruby_path
diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb
new file mode 100644
index 00000000..ceea71f5
--- /dev/null
+++ b/lib/hive/commands/setup.rb
@@ -0,0 +1,317 @@
+require "fileutils"
+require "json"
+require "stringio"
+require "hive"
+require "hive/config"
+require "hive/invoked_binary"
+require "hive/paths"
+require "hive/setup/diagnostics"
+require "hive/web/environment"
+
+module Hive
+  module Commands
+    class Setup
+      PHASE_REMEDIATIONS = {
+        "diagnostics" => [ "Fix the failed prerequisites above, then run `hive setup` again." ],
+        "qmd" => [ "Install qmd with `npm install --global @tobilu/qmd`, then run `hive setup` again." ],
+        "web_bundle" => [ "Run `hive web install` to retry the authenticated web bundle installation." ],
+        "daemon_service" => [ "Run `hive daemon install --force`, then inspect `hive daemon status --json`." ],
+        "daemon_readiness" => [ "Run `hive daemon status --json`, fix the reported condition, then rerun `hive setup`." ],
+        "repository_enrollment" => [ "Run `hive init` in the repository, then rerun `hive setup`." ],
+        "web_service" => [ "Run `hive web install`, then inspect `hive web status --json`." ],
+        "web_readiness" => [ "Run `hive web status --json`, fix the reported condition, then rerun `hive setup`." ],
+        "setup" => [ "Rerun `hive setup`; if it still fails, inspect the preceding phase and diagnostic output." ]
+      }.freeze
+
+      FOREGROUND_COMMAND = "hive daemon start --detach && hive web".freeze
+
+      def initialize(project_root: Dir.pwd, no_bootstrap: false, no_init: false,
+                     no_service: false, json: false, output: $stdout,
+                     diagnostics: nil, qmd_bootstrap: nil, bundle_installer: nil,
+                     daemon_installer: nil, daemon_status: nil, enroller: nil,
+                     web_installer: nil, web_status: nil, binary: nil)
+        @project_root = File.expand_path(project_root)
+        @no_bootstrap = no_bootstrap
+        @no_init = no_init
+        @no_service = no_service
+        @json = json
+        @output = output
+        @binary = binary || Hive::InvokedBinary.path || File.expand_path("../../../bin/hive", __dir__)
+        @diagnostics = diagnostics || Hive::Setup::Diagnostics.new
+        @qmd_bootstrap = qmd_bootstrap || -> { bootstrap_qmd }
+        @bundle_installer = bundle_installer || -> { install_bundle }
+        @daemon_installer = daemon_installer || ->(path) { install_daemon(path) }
+        @daemon_status = daemon_status || ->(path) { daemon_report(path) }
+        @enroller = enroller || -> { enroll_repository }
+        @web_installer = web_installer || ->(path) { install_web(path) }
+        @web_status = web_status || -> { web_report }
+        @phases = []
+        @phase_values = {}
+      end
+
+      def call
+        checks = @diagnostics.call
+        if blocking_checks(checks).any?
+          remediation = blocking_checks(checks).flat_map do |check|
+            check.respond_to?(:remediation) ? Array(check.remediation) : []
+          end.compact.uniq
+          phase("diagnostics", false, "mandatory prerequisites are not ready", remediation: remediation)
+          return finish(checks, nil)
+        end
+        phase("diagnostics", true, "supported platform and operator-owned prerequisites are ready")
+
+        qmd = checks.find { |check| check.name == "qmd" }
+        if qmd && !qmd.success? && !run_phase("qmd", &@qmd_bootstrap)
+          return finish(checks, nil)
+        end
+        return finish(checks, nil) unless run_phase("web_bundle", &@bundle_installer)
+        return finish(checks, nil) unless run_phase("daemon_service") { @daemon_installer.call(@binary) }
+
+        daemon_fallback = service_fallback?(@phase_values["daemon_service"])
+        if daemon_fallback
+          phase(
+            "daemon_readiness",
+            true,
+            "service manager unavailable; foreground daemon will be required"
+          )
+        else
+          return finish(checks, nil) unless run_phase("daemon_readiness") do
+            @daemon_status.call(@binary)["ready"] == true
+          end
+        end
+        return finish(checks, nil) unless @no_init || run_phase("repository_enrollment", &@enroller)
+
+        url = Hive::Web::Environment.new(config: Hive::Config.load_global_web).url
+        if @no_service
+          phase("web_service", true, "skipped by --no-service; run `hive web` in the foreground")
+          @output.puts "hive setup: foreground web is ready to run: hive web" unless @json
+          return finish(checks, url)
+        end
+
+        if daemon_fallback
+          phase(
+            "web_service",
+            false,
+            "service manager unavailable; managed services were not accepted",
+            remediation: [ "Run `#{FOREGROUND_COMMAND}`." ]
+          )
+          @output.puts "hive setup: autostart unavailable; run: #{FOREGROUND_COMMAND}" unless @json
+          return finish(checks, url)
+        end
+
+        return finish(checks, nil) unless run_phase("web_service") { @web_installer.call(@binary) }
+        if service_fallback?(@phase_values["web_service"])
+          phase(
+            "web_readiness",
+            false,
+            "web autostart unavailable; foreground web is required",
+            remediation: [ "Run `hive web`." ]
+          )
+          @output.puts "hive setup: web autostart unavailable; run: hive web" unless @json
+          return finish(checks, url)
+        end
+        return finish(checks, nil) unless run_phase("web_readiness") do
+          status = @web_status.call
+          url = status["url"] if status["url"]
+          status["ready"] == true
+        end
+        finish(checks, url)
+      rescue StandardError => e
+        phase("setup", false, "#{e.class}: #{e.message}")
+        finish([], nil)
+      end
+
+      private
+
+      def blocking_checks(checks)
+        checks.select do |check|
+          next false if check.success?
+
+          @no_bootstrap || !check.bootstrappable
+        end
+      end
+
+      def run_phase(name)
+        value = yield
+        @phase_values[name] = value
+        ok = value != false && (!value.respond_to?(:success?) || value.success?)
+        phase(name, ok, ok ? "complete" : "failed")
+        ok
+      rescue StandardError => e
+        phase(name, false, "#{e.class}: #{e.message}")
+        false
+      end
+
+      def phase(name, ok, message, remediation: nil)
+        fixes = if ok
+          []
+        else
+          supplied = Array(remediation).reject(&:empty?)
+          supplied.empty? ? PHASE_REMEDIATIONS.fetch(name, PHASE_REMEDIATIONS["setup"]) : supplied
+        end
+        @phases << {
+          "name" => name,
+          "ok" => ok,
+          "message" => message,
+          "remediation" => fixes
+        }
+      end
+
+      def finish(checks, url)
+        ok = @phases.all? { |entry| entry["ok"] }
+        payload = {
+          "schema" => "hive-setup",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"),
+          "ok" => ok,
+          "checks" => checks.map(&:to_h),
+          "phases" => @phases,
+          "url" => url
+        }
+        if @json
+          @output.puts JSON.generate(payload)
+        else
+          @phases.each do |entry|
+            icon = entry["ok"] ? "✓" : "✗"
+            @output.puts "  #{icon} #{entry['name']}: #{entry['message']}"
+          end
+          @output.puts("hive setup: ready at #{url}") if ok && url && !@no_service
+          unless ok
+            @phases.reject { |entry| entry["ok"] }.each do |entry|
+              entry["remediation"].each do |line|
+                @output.puts "    fix #{entry['name']}: #{line}"
+              end
+            end
+            checks.reject(&:success?).each do |check|
+              Array(check.respond_to?(:remediation) ? check.remediation : []).each do |line|
+                @output.puts "    fix #{check.name}: #{line}"
+              end
+            end
+          end
+        end
+        payload
+      end
+
+      def bootstrap_qmd
+        prefix = File.join(Hive::Paths.data_home, "qmd")
+        package = ENV.fetch("HIVE_QMD_NPM_PACKAGE", "@tobilu/qmd")
+        FileUtils.mkdir_p(prefix)
+        ok = system(
+          "npm", "install", "--global", "--prefix", prefix,
+          "--no-audit", "--no-fund", package,
+          **(@json ? { out: File::NULL, err: File::NULL } : {})
+        )
+        return false unless ok
+
+        source = File.join(prefix, "bin", "qmd")
+        FileUtils.mkdir_p(Hive::Paths.bin_home)
+        target = File.join(Hive::Paths.bin_home, "qmd")
+        FileUtils.ln_sf(source, target)
+        File.file?(source)
+      end
+
+      def install_bundle
+        require "hive/web/app_bundle"
+        Hive::Web::AppBundle.new(cli_root: cli_root, quiet: @json).install!
+        true
+      end
+
+      def install_daemon(binary)
+        require "hive/commands/daemon/service_installer"
+        outcome = Hive::Commands::Daemon::ServiceInstaller.new(
+          binary_path: binary
+        ).install!(autostart: true, force: true)
+        outcome
+      end
+
+      def daemon_report(binary)
+        require "hive/commands/daemon"
+        require "hive/commands/daemon/service_installer"
+        require "hive/daemon/status_report"
+        command = Hive::Commands::Daemon.new("status")
+        deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
+        loop do
+          report = Hive::Daemon::StatusReport.new(
+            installer: Hive::Commands::Daemon::ServiceInstaller.new(binary_path: binary),
+            pid_probe: command,
+            pid_file: command.pid_file,
+            log_file: command.log_file,
+            current_binary: binary
+          ).to_h
+          return report if report["ready"]
+          return report if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+          sleep 0.25
+        end
+      end
+
+      def enroll_repository
+        unless File.directory?(File.join(@project_root, ".git"))
+          raise Hive::Error,
+                "hive setup: #{@project_root} is not a Git repository; pass --no-init outside a repository"
+        end
+        unless File.directory?(File.join(@project_root, ".hive-state"))
+          require "hive/commands/init"
+          begin
+            suppress_nested_output do
+              Hive::Commands::Init.new(@project_root, force: false, json: false).call
+            end
+          rescue Hive::AlreadyInitialized
+            # The registry/state checks below are the source of truth.
+          end
+        end
+        project = Hive::Config.registered_projects.find do |entry|
+          File.expand_path(entry.fetch("path")) == @project_root
+        end
+        raise Hive::ConfigError, "hive setup: repository enrollment did not register #{@project_root}" unless project
+
+        require "hive/commands/daemon"
+        suppress_nested_output do
+          Hive::Commands::Daemon.new("enable", project.fetch("name")).call
+        end
+        true
+      end
+
+      def install_web(binary)
+        require "hive/commands/web/service_installer"
+        outcome = Hive::Commands::Web::ServiceInstaller.new(
+          binary_path: binary
+        ).install!(autostart: true, force: true)
+        outcome
+      end
+
+      def web_report
+        require "hive/commands/web/service_installer"
+        require "hive/web/service_status"
+        environment = Hive::Web::Environment.new(config: Hive::Config.load_global_web)
+        installer = Hive::Commands::Web::ServiceInstaller.new(binary_path: @binary)
+        deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
+        loop do
+          report = Hive::Web::ServiceStatus.new(installer: installer, url: environment.url).to_h
+          return report if report["ready"]
+          return report if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+          sleep 0.25
+        end
+      end
+
+      def cli_root
+        Gem.loaded_specs["hive-cli"]&.full_gem_path || File.expand_path("../../..", __dir__)
+      end
+
+      def service_fallback?(value)
+        value.respond_to?(:kind) && %i[autostart_unavailable unsupported].include?(value.kind)
+      end
+
+      def suppress_nested_output
+        return yield unless @json
+
+        previous_stdout = $stdout
+        previous_stderr = $stderr
+        $stdout = StringIO.new
+        $stderr = StringIO.new
+        yield
+      ensure
+        $stdout = previous_stdout if previous_stdout
+        $stderr = previous_stderr if previous_stderr
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb
index 57f319a2..f7056f0e 100644
--- a/lib/hive/commands/uninstall.rb
+++ b/lib/hive/commands/uninstall.rb
@@ -21,7 +21,15 @@ module Hive
         projects = registered_projects
         deregister_daemon
         deregister_bot
+        web_deregistered = deregister_web
+        unless web_deregistered
+          @output.puts "hive: preserving the web runtime, configuration, and executable links because the web service is still registered"
+          @output.puts "hive: uninstall stopped; fix service-manager state and rerun `hive uninstall`"
+          return 1
+        end
+
         remove_user_config_and_cache
+        remove_web_runtime
         remove_data_versions
         remove_user_symlinks
         cleanup_project_state(projects)
@@ -54,6 +62,19 @@ module Hive
         deregister_unit(Hive::Commands::Bot::ServiceInstaller.new(host_os: @host_os))
       end
 
+      def deregister_web
+        require "hive/commands/web/service_installer"
+        deregister_unit(Hive::Commands::Web::ServiceInstaller.new(host_os: @host_os))
+      end
+
+      def remove_web_runtime
+        return if Hive::Paths.hive_home_collapsed?
+
+        FileUtils.rm_rf(Hive::Paths.web_app_home)
+        FileUtils.rm_rf(Hive::Paths.web_gems_home)
+        FileUtils.rm_rf(Hive::Paths.web_storage_home) if @force_purge_state
+      end
+
       # Deregister a per-user autostart unit using the installer's OWN
       # identity (`target_path` / `service_name`) as the single source of
       # truth, so install and uninstall can never drift on paths or names —
@@ -62,14 +83,14 @@ module Hive
       # failure so one stuck manager never aborts the rest of the uninstall.
       def deregister_unit(installer)
         path = installer.target_path
-        return unless path && File.exist?(path)
+        return true unless path && File.exist?(path)
 
         case @host_os
         when /darwin/i
           ok = @runner.call([ "launchctl", "unload", path ])
           unless ok
             @output.puts "hive: warning: launchctl unload failed for #{path}; leaving it in place. Fix launchd state and re-run `hive uninstall`."
-            return
+            return false
           end
           safe_unlink(path)
         when /linux/i
@@ -77,11 +98,15 @@ module Hive
           ok = @runner.call([ "systemctl", "--user", "disable", "--now", service ])
           unless ok
             @output.puts "hive: warning: systemctl --user disable failed for #{service}; leaving #{path} in place. Fix systemd state and re-run `hive uninstall`."
-            return
+            return false
           end
-          safe_unlink(path)
+          return false unless safe_unlink(path)
+
           ok_reload = @runner.call(%w[systemctl --user daemon-reload])
           @output.puts "hive: warning: systemctl --user daemon-reload failed after removing #{path}; run it manually" unless ok_reload
+          true
+        else
+          true
         end
       end
 
@@ -122,11 +147,12 @@ module Hive
         stat = File.lstat(path)
         if stat.symlink?
           @output.puts "hive: refusing to follow symlink at #{path}; remove it manually"
-          return
+          return false
         end
         FileUtils.rm_f(path)
+        true
       rescue Errno::ENOENT
-        nil
+        true
       end
 
       def remove_user_config_and_cache
diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb
index eb3cd40f..54fac0ea 100644
--- a/lib/hive/commands/web.rb
+++ b/lib/hive/commands/web.rb
@@ -1,48 +1,67 @@
 require "hive/config"
+require "hive/invoked_binary"
+require "hive/paths"
+require "hive/web/app_bundle"
+require "hive/web/environment"
 require "hive/web/session_secret"
+require "fileutils"
+require "socket"
+require "json"
 
 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 Hive's Rails UI from a source checkout, Docker/hivebox app, or
+    # version-matched managed release bundle. The core CLI gem stays lean.
     class Web
-      def initialize(bind: nil, port: nil)
+      VALID_SUBCOMMANDS = %w[install start stop status].freeze
+
+      def initialize(subcommand = nil, bind: nil, port: nil, unsafe: false,
+                     force: false, json: false)
+        @subcommand = subcommand
         @bind = bind
         @port = port
+        @unsafe = unsafe
+        @force = force
+        @json = json
       end
 
       def call
-        cfg = Hive::Config.load_global_web
-        bind = @bind || cfg.fetch("bind")
-        port = (@port || cfg.fetch("port")).to_i
-        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."
-          exit 1
-        end
+        @stdout_written = false
+        return call_lifecycle if @subcommand
 
-        warn_on_public_bind(bind, cfg)
+        run_foreground
+      rescue Hive::Error => e
+        emit_lifecycle_error(e) if @json && @subcommand && !@stdout_written
+        raise
+      rescue StandardError => e
+        wrapped = Hive::Error.new("hive web: #{e.class}: #{e.message}")
+        emit_lifecycle_error(wrapped) if @json && @subcommand && !@stdout_written
+        raise wrapped
+      end
 
-        env = {
+      private
+
+      def run_foreground
+        cfg = Hive::Config.load_global_web
+        environment = web_environment(cfg)
+        environment.validate_security!
+        ensure_port_available!(environment.bind, environment.port)
+        app_dir = resolve_rails_app_dir
+        env = environment.to_h(
+          app_dir: app_dir,
+          cli_root: cli_root,
+          managed: managed_app?(app_dir)
+        ).merge(
           "RAILS_ENV" => ENV.fetch("RAILS_ENV", "production"),
+          "HIVE_INVOKED_BIN" => current_binary_path,
           # 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")
-        }
-        FileUtils.mkdir_p(env.fetch("HIVEBOX_STORAGE_DIR"))
+            Hive::Web::SessionSecret.load_or_create(cfg.fetch("session_secret_file"))
+        )
+        storage_dir = env["HIVE_WEB_STORAGE_DIR"] || File.join(app_dir, "storage")
+        FileUtils.mkdir_p(storage_dir)
 
         Dir.chdir(app_dir) do
           # Idempotent: creates/migrates the solid-stack sqlite databases on
@@ -52,36 +71,236 @@ module Hive
           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) " \
+                  "#{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}"
+          puts "hive web: listening on #{environment.url}"
           # 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
+          Kernel.exec env, "bin/rails", "server", "-b", environment.bind, "-p", environment.port.to_s
         end
       end
 
-      private
+      def call_lifecycle
+        unless VALID_SUBCOMMANDS.include?(@subcommand)
+          raise Hive::InvalidTaskPath,
+                "hive web: unknown subcommand #{@subcommand.inspect} " \
+                "(expected: #{VALID_SUBCOMMANDS.join(', ')})"
+        end
+        case @subcommand
+        when "install" then install_service
+        when "start" then start_service
+        when "stop" then stop_service
+        when "status" then status_service
+        end
+      end
+
+      def install_service
+        Hive::Web::AppBundle.new(cli_root: cli_root, quiet: @json).install!
+        installer = service_installer
+        outcome = installer.install!(autostart: true, force: @force)
+        installer.messages.each { |message| warn "hive: #{message}" } unless @json
+        payload = {
+          "schema" => "hive-web-install",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"),
+          "ok" => outcome.success?,
+          "outcome" => outcome.wire_outcome,
+          "platform" => installer.envelope_platform,
+          "unit_path" => installer.target_path,
+          "backup_path" => outcome.backup_path,
+          "restarted" => outcome.restarted,
+          "url" => configured_environment.url,
+          "messages" => installer.messages
+        }
+        if @json
+          puts JSON.generate(payload)
+          @stdout_written = true
+        end
+        puts "hive web: service #{outcome.wire_outcome} at #{installer.target_path}" unless @json
+        raise Hive::Error, "hive web: service install #{outcome.wire_outcome}" unless outcome.success?
+        payload
+      end
+
+      def start_service
+        installer = service_installer
+        unless installer.service_state["service_installed"]
+          raise Hive::Error, "hive web: service is not installed; run `hive web install`"
+        end
+        raise Hive::Error, "hive web: service manager failed to start hive-web" unless installer.start!
+
+        status = wait_for_readiness(installer)
+        emit_status(status, operation: "start", ok: status["ready"])
+        raise Hive::Error, "hive web: service started but /health did not become ready" unless status["ready"]
+        status
+      end
+
+      def stop_service
+        installer = service_installer
+        raise Hive::Error, "hive web: service manager failed to stop hive-web" unless installer.stop!
+
+        status = service_status(installer).to_h
+        emit_status(status, operation: "stop", ok: true)
+        status
+      end
+
+      def status_service
+        status = service_status(service_installer).to_h
+        emit_status(status, operation: "status", ok: status["ready"])
+        raise Hive::Error, "web service is not ready (#{status['failure']})" unless status["ready"]
+        status
+      end
+
+      def emit_status(status, operation:, ok:)
+        payload = {
+          "schema" => "hive-web-status",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"),
+          "ok" => ok,
+          "operation" => operation
+        }.merge(status)
+        if @json
+          puts JSON.generate(payload)
+          @stdout_written = true
+        else
+          state = status["ready"] ? "ready" : (status["running"] ? "running, not ready" : "stopped")
+          puts "hive web: #{state} — #{status['url']}"
+          warn "hive web: #{status['failure']}" if status["failure"]
+          warn "hive web: UNSAFE non-loopback service exposure" if status["unsafe"]
+        end
+      end
+
+      def emit_lifecycle_error(error)
+        if @subcommand == "install"
+          metadata = safe_installer_metadata
+          payload = {
+            "schema" => "hive-web-install",
+            "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"),
+            "ok" => false,
+            "outcome" => "failed",
+            "platform" => metadata.fetch("platform"),
+            "unit_path" => metadata["unit_path"],
+            "backup_path" => nil,
+            "restarted" => false,
+            "url" => safe_configured_url,
+            "messages" => [ error.message ]
+          }
+        else
+          payload = {
+            "schema" => "hive-web-status",
+            "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"),
+            "ok" => false,
+            "operation" => @subcommand,
+            "platform" => nil,
+            "unit_path" => nil,
+            "service_installed" => nil,
+            "service_enabled" => nil,
+            "running" => false,
+            "ready" => false,
+            "url" => safe_configured_url,
+            "desired_url" => safe_configured_url,
+            "configuration_drift" => false,
+            "unsafe" => @unsafe == true,
+            "failure" => "command_failed",
+            "message" => error.message
+          }
+        end
+        puts JSON.generate(payload)
+        @stdout_written = true
+      end
+
+      def safe_installer_metadata
+        installer = service_installer
+        {
+          "platform" => installer.envelope_platform,
+          "unit_path" => installer.target_path
+        }
+      rescue StandardError
+        { "platform" => "unsupported", "unit_path" => nil }
+      end
+
+      def safe_configured_url
+        configured_environment.url
+      rescue StandardError
+        bind = @bind.to_s.empty? ? "127.0.0.1" : @bind
+        port = @port || 4567
+        host = bind == "::1" ? "[::1]" : bind
+        "http://#{host}:#{port}"
+      end
+
+      def wait_for_readiness(installer, timeout: 30)
+        deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+        loop do
+          status = service_status(installer).to_h
+          return status if status["ready"]
+          return status if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+          sleep 0.25
+        end
+      end
+
+      def service_installer
+        require "hive/commands/web/service_installer"
+        Hive::Commands::Web::ServiceInstaller.new(
+          binary_path: current_binary_path,
+          bind: @bind,
+          port: @port,
+          unsafe: @unsafe
+        )
+      end
+
+      def service_status(installer)
+        require "hive/web/service_status"
+        Hive::Web::ServiceStatus.new(installer: installer, url: configured_environment.url)
+      end
+
+      def configured_environment
+        web_environment(Hive::Config.load_global_web)
+      end
 
       def rails_app_dir
         candidates = [
+          ENV["HIVE_WEB_APP_DIR"],
           ENV["HIVEBOX_WEB_APP_DIR"],
-          File.expand_path("../../../web", __dir__)
+          File.expand_path("../../../web", __dir__),
+          Hive::Paths.web_app_home
         ].compact
         candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) }
       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://")
+      def resolve_rails_app_dir
+        rails_app_dir || Hive::Web::AppBundle.new(cli_root: cli_root, quiet: @json).install!
+      rescue Hive::Error
+        raise
+      rescue StandardError => e
+        raise Hive::Error, "hive web: could not install the matching Rails bundle: #{e.message}"
+      end
+
+      def web_environment(cfg)
+        Hive::Web::Environment.new(
+          config: cfg, bind: @bind, port: @port, unsafe: @unsafe
+        )
+      end
+
+      def cli_root
+        spec = Gem.loaded_specs["hive-cli"]
+        spec ? spec.full_gem_path : File.expand_path("../../..", __dir__)
+      end
+
+      def current_binary_path
+        Hive::InvokedBinary.path || File.expand_path("../../../bin/hive", __dir__)
+      end
+
+      def managed_app?(app_dir)
+        File.expand_path(app_dir) == File.expand_path(Hive::Paths.web_app_home)
+      end
 
-        warn "hive web: WARNING binding 0.0.0.0 without an https origin — " \
-             "ensure a trusted reverse proxy validates the Host header."
+      def ensure_port_available!(bind, port)
+        server = TCPServer.new(bind, port)
+        server.close
+      rescue Errno::EADDRINUSE
+        raise Hive::Error,
+              "hive web: #{bind}:#{port} is already in use; stop the conflicting process " \
+              "or choose another port with --port"
+      rescue SocketError, SystemCallError => e
+        raise Hive::Error, "hive web: cannot bind #{bind}:#{port}: #{e.message}"
       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..7d35e74f
--- /dev/null
+++ b/lib/hive/commands/web/service_installer.rb
@@ -0,0 +1,131 @@
+require "cgi"
+require "shellwords"
+require "rexml/document"
+require "hive/config"
+require "hive/paths"
+require "hive/commands/service_installer/base"
+require "hive/web/environment"
+
+module Hive
+  module Commands
+    class Web
+      class ServiceInstaller < Hive::Commands::ServiceInstaller::Base
+        def initialize(bind: nil, port: nil, unsafe: false, **kwargs)
+          @bind = bind
+          @port = port
+          @unsafe = unsafe
+          super(**kwargs)
+        end
+
+        def service_name = "hive-web"
+        def cli_label = "web"
+        def service_noun = "web service"
+        def unit_noun = "web unit"
+
+        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
+
+        def installed_settings
+          path = target_path
+          return { "readable" => false, "message" => "web unit is not installed" } unless path && File.exist?(path)
+
+          values = envelope_platform == "macos" ? launchd_environment(path) : systemd_environment_values(path)
+          bind = values.fetch("HIVE_WEB_BIND")
+          port = Integer(values.fetch("HIVE_WEB_PORT"))
+          {
+            "readable" => true,
+            "bind" => bind,
+            "port" => port,
+            "unsafe" => values["HIVE_WEB_UNSAFE"] == "true",
+            "url" => Hive::Web::Environment.new(
+              config: Hive::Config.load_global_web,
+              bind: bind,
+              port: port,
+              unsafe: values["HIVE_WEB_UNSAFE"] == "true"
+            ).url,
+            "configuration_drift" => File.read(path) != rendered_definition
+          }
+        rescue KeyError, ArgumentError, REXML::ParseException, SystemCallError => e
+          { "readable" => false, "message" => e.message }
+        end
+
+        private
+
+        def render_systemd
+          template = File.read(File.expand_path("../../../../examples/systemd/hive-web.service", __dir__))
+          lines = service_environment.reject { |key, _value| key == "PATH" }
+                                     .map { |key, value| systemd_environment(key, value) }
+          template
+            .sub(/^ExecStart=.*$/, "ExecStart=#{Shellwords.escape(resolved_binary)} web")
+            .sub(/^Environment=PATH=.*$/, ([ build_path_line ] + lines).join("\n"))
+        end
+
+        def render_launchd
+          template = File.read(File.expand_path("../../../../examples/launchd/hive-web.plist", __dir__))
+          environment_xml = service_environment.map do |key, value|
+            "    <key>#{CGI.escapeHTML(key)}</key>\n    <string>#{CGI.escapeHTML(value)}</string>"
+          end.join("\n")
+          template
+            .gsub("/Users/YOU/.local/bin/hive", CGI.escapeHTML(resolved_binary))
+            .gsub("/Users/YOU/Library/Logs", "#{CGI.escapeHTML(@home)}/Library/Logs")
+            .sub(
+              %r{  <key>EnvironmentVariables</key>\n  <dict>.*?</dict>}m,
+              "  <key>EnvironmentVariables</key>\n  <dict>\n#{environment_xml}\n  </dict>"
+            )
+        end
+
+        def service_environment
+          cfg = Hive::Config.load_global_web
+          web = Hive::Web::Environment.new(
+            config: cfg, bind: @bind, port: @port, unsafe: @unsafe
+          )
+          cli_root = Gem.loaded_specs["hive-cli"]&.full_gem_path ||
+            File.expand_path("../../../..", __dir__)
+          web.to_h(app_dir: Hive::Paths.web_app_home, cli_root: cli_root, managed: true).merge(
+            "HIVE_WEB_APP_DIR" => Hive::Paths.web_app_home,
+            "HIVE_INVOKED_BIN" => resolved_binary,
+            "PATH" => launchd_path
+          )
+        end
+
+        def rendered_definition
+          envelope_platform == "macos" ? render_launchd : render_systemd
+        end
+
+        def systemd_environment_values(path)
+          File.readlines(path, chomp: true).filter_map do |line|
+            next unless line.start_with?("Environment=")
+
+            entry = Shellwords.split(line.delete_prefix("Environment=")).first
+            entry&.split("=", 2)
+          end.to_h
+        end
+
+        def launchd_environment(path)
+          document = REXML::Document.new(File.read(path))
+          dict = REXML::XPath.first(
+            document,
+            "/plist/dict/key[.='EnvironmentVariables']/following-sibling::dict[1]"
+          )
+          return {} unless dict
+
+          elements = dict.elements.to_a
+          elements.each_with_index.filter_map do |element, index|
+            next unless element.name == "key"
+
+            [ element.text.to_s, elements[index + 1]&.text.to_s ]
+          end.to_h
+        end
+
+        def systemd_environment(key, value)
+          escaped = "#{key}=#{value}".gsub("\\", "\\\\\\\\").gsub("\"", "\\\"").gsub("%", "%%")
+          "Environment=\"#{escaped}\""
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/daemon/status_report.rb b/lib/hive/daemon/status_report.rb
new file mode 100644
index 00000000..f1cbc533
--- /dev/null
+++ b/lib/hive/daemon/status_report.rb
@@ -0,0 +1,164 @@
+require "json"
+require "open3"
+require "rexml/document"
+require "shellwords"
+require "timeout"
+require "hive"
+require "hive/bounded_process"
+require "hive/invoked_binary"
+require "hive/web/daemon_maintenance"
+
+module Hive
+  module Daemon
+    class StatusReport
+      DRIFT_VALUES = %w[none path version unparseable unreadable not_applicable].freeze
+
+      def initialize(installer:, pid_probe:, pid_file:, log_file:,
+                     current_binary: nil, current_version: Hive::VERSION,
+                     runner: nil, timeout: 3)
+        @installer = installer
+        @pid_probe = pid_probe
+        @pid_file = pid_file
+        @log_file = log_file
+        @current_binary = current_binary || Hive::InvokedBinary.path || $PROGRAM_NAME
+        @current_version = current_version
+        @runner = runner || method(:run_probe)
+        @timeout = timeout
+      end
+
+      def to_h
+        process = process_state
+        service = @installer.service_state
+        installed = installed_state(service)
+        drift = classify_drift(service, installed)
+        service.merge(process).merge(
+          "installed_executable" => installed[:executable],
+          "installed_version" => installed[:version],
+          "current_executable" => @current_binary,
+          "current_version" => @current_version,
+          "drift" => drift,
+          "drift_message" => drift_message(drift, installed),
+          "last_maintenance" => Hive::Web::DaemonMaintenance.last_result,
+          "ready" => process["running"] &&
+            service["service_installed"] == true &&
+            service["service_enabled"] == true &&
+            drift == "none"
+        )
+      rescue StandardError => e
+        {
+          "platform" => nil, "unit_path" => nil,
+          "service_installed" => nil, "service_enabled" => nil,
+          "installed_executable" => nil, "installed_version" => nil,
+          "current_executable" => @current_binary, "current_version" => @current_version,
+          "drift" => "unreadable", "drift_message" => e.message,
+          "last_maintenance" => Hive::Web::DaemonMaintenance.last_result,
+          "ready" => false
+        }.merge(process || {
+          "running" => false, "pid" => nil, "uptime_sec" => nil,
+          "pid_file" => @pid_file, "log_file" => @log_file
+        })
+      end
+
+      private
+
+      def process_state
+        pid = @pid_probe.send(:read_live_pid)
+        uptime = pid && File.file?(@pid_file) ? (Time.now - File.stat(@pid_file).mtime).to_i : nil
+        {
+          "running" => !pid.nil?,
+          "pid" => pid,
+          "uptime_sec" => uptime,
+          "pid_file" => @pid_file,
+          "log_file" => @log_file
+        }
+      rescue StandardError
+        {
+          "running" => false, "pid" => nil, "uptime_sec" => nil,
+          "pid_file" => @pid_file, "log_file" => @log_file
+        }
+      end
+
+      def installed_state(service)
+        return { executable: nil, version: nil, message: nil, state: :absent } unless service["service_installed"]
+
+        path = service["unit_path"]
+        return { executable: nil, version: nil, message: "unit path is unavailable", state: :unreadable } unless path
+
+        executable = parse_executable(path, service["platform"])
+        return { executable: nil, version: nil, message: "Hive executable not found in service definition", state: :unparseable } unless executable
+
+        probe = @runner.call([ executable, "--version" ], timeout: @timeout)
+        version = probe[:ok] ? extract_version(probe[:stdout], probe[:stderr]) : nil
+        state = version ? :parsed : :unparseable
+        message = version ? nil : probe_message(probe)
+        { executable: executable, version: version, message: message, state: state }
+      rescue Errno::EACCES, Errno::ENOENT, Errno::EISDIR => e
+        { executable: nil, version: nil, message: e.message, state: :unreadable }
+      rescue REXML::ParseException, ArgumentError => e
+        { executable: nil, version: nil, message: e.message, state: :unparseable }
+      end
+
+      def parse_executable(path, platform)
+        argv =
+          if platform == "macos"
+            document = REXML::Document.new(File.read(path))
+            array = REXML::XPath.first(
+              document,
+              "/plist/dict/key[.='ProgramArguments']/following-sibling::array[1]"
+            )
+            array ? array.get_elements("string").map(&:text) : []
+          else
+            line = File.readlines(path, chomp: true).find { |candidate| candidate.start_with?("ExecStart=") }
+            line ? Shellwords.split(line.delete_prefix("ExecStart=")) : []
+          end
+        argv.find { |token| %w[hive hv].include?(File.basename(token.to_s)) }
+      end
+
+      def classify_drift(service, installed)
+        return "not_applicable" unless service["service_installed"]
+        return installed[:state].to_s if %i[unreadable unparseable].include?(installed[:state])
+        return "path" unless same_path?(installed[:executable], @current_binary)
+        return "version" unless installed[:version] == @current_version
+
+        "none"
+      end
+
+      def drift_message(drift, installed)
+        case drift
+        when "none" then nil
+        when "path"
+          "Service uses #{installed[:executable]}; the current Hive executable is #{@current_binary}."
+        when "version"
+          "Service resolves Hive #{installed[:version] || 'unknown'}; the current version is #{@current_version}."
+        when "not_applicable"
+          "The daemon service is not installed."
+        else
+          installed[:message] || "The daemon service definition could not be verified."
+        end
+      end
+
+      def same_path?(left, right)
+        File.expand_path(left.to_s) == File.expand_path(right.to_s)
+      end
+
+      def extract_version(*values)
+        text = values.join(" ")
+        text[/\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?/]
+      end
+
+      def probe_message(probe)
+        [ probe[:stderr], probe[:stdout], "exit #{probe[:exit_code]}" ]
+          .find { |value| !value.to_s.strip.empty? }.to_s.lines.first.to_s.strip
+      end
+
+      def run_probe(argv, timeout:)
+        stdout, stderr, status = Hive::BoundedProcess.capture3(*argv, timeout: timeout)
+        { ok: status.success?, stdout: stdout, stderr: stderr, exit_code: status.exitstatus }
+      rescue Timeout::Error
+        { ok: false, stdout: "", stderr: "version probe timed out after #{timeout}s", exit_code: nil }
+      rescue SystemCallError => e
+        { ok: false, stdout: "", stderr: e.message, exit_code: nil }
+      end
+    end
+  end
+end
diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb
index 752a5c7d..214f8f83 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
 
+    def web_app_home
+      File.join(data_home, "web")
+    end
+
+    def web_gems_home
+      File.join(data_home, "web-gems")
+    end
+
+    def web_storage_home
+      File.join(state_home, "web-storage")
+    end
+
     def task_counter_path
       File.join(state_home, "task-counter.yml")
     end
diff --git a/lib/hive/setup/check_result.rb b/lib/hive/setup/check_result.rb
new file mode 100644
index 00000000..f2ca801d
--- /dev/null
+++ b/lib/hive/setup/check_result.rb
@@ -0,0 +1,42 @@
+module Hive
+  module Setup
+    class CheckResult
+      STATUSES = %w[pass warning fail missing].freeze
+
+      attr_reader :name, :category, :detected, :required, :status,
+                  :remediation, :bootstrappable, :message
+
+      def initialize(name:, category:, detected:, required:, status:, remediation:,
+                     bootstrappable:, message: nil)
+        raise ArgumentError, "unknown setup check status #{status.inspect}" unless STATUSES.include?(status)
+
+        @name = name
+        @category = category
+        @detected = detected
+        @required = required
+        @status = status
+        @remediation = Array(remediation).freeze
+        @bootstrappable = !!bootstrappable
+        @message = message
+        freeze
+      end
+
+      def success?
+        status == "pass" || status == "warning"
+      end
+
+      def to_h
+        {
+          "name" => name,
+          "category" => category,
+          "detected" => detected,
+          "required" => required,
+          "status" => status,
+          "remediation" => remediation,
+          "bootstrappable" => bootstrappable,
+          "message" => message
+        }
+      end
+    end
+  end
+end
diff --git a/lib/hive/setup/diagnostics.rb b/lib/hive/setup/diagnostics.rb
new file mode 100644
index 00000000..8aacb87a
--- /dev/null
+++ b/lib/hive/setup/diagnostics.rb
@@ -0,0 +1,237 @@
+require "open3"
+require "rubygems"
+require "timeout"
+require "hive"
+require "hive/bounded_process"
+require "hive/paths"
+require "hive/setup/check_result"
+
+module Hive
+  module Setup
+    class Diagnostics
+      DEFAULT_TIMEOUT = 5
+      AUTH_PROBES = {
+        "gh" => {
+          argv: %w[gh auth status],
+          remediation: "gh auth login"
+        },
+        "claude" => {
+          argv: %w[claude auth status],
+          remediation: "claude auth login"
+        },
+        "codex" => {
+          argv: %w[codex login status],
+          remediation: "codex login"
+        }
+      }.freeze
+
+      def initialize(host_os: RbConfig::CONFIG["host_os"], ruby_version: RUBY_VERSION,
+                     runner: nil, which: nil, web_bundle_current: nil,
+                     timeout: DEFAULT_TIMEOUT)
+        @host_os = host_os
+        @ruby_version = ruby_version
+        @runner = runner || method(:run_probe)
+        @which = which || method(:which)
+        @web_bundle_current = web_bundle_current || method(:web_bundle_current?)
+        @timeout = timeout
+      end
+
+      def call
+        platform = platform_result
+        return [ platform ] unless platform.success?
+
+        [
+          platform,
+          ruby_result,
+          command_result("git", category: "tool", remediation: install_hint("git")),
+          command_result("tmux", category: "tool", remediation: install_hint("tmux")),
+          command_result("node", category: "runtime", remediation: node_hint),
+          command_result("npm", category: "runtime", remediation: node_hint),
+          command_result("sqlite3", category: "runtime", remediation: install_hint("sqlite3")),
+          cosign_result,
+          command_result("qmd", category: "hive_owned", remediation: "hive setup",
+                                  bootstrappable: true, version_argv: %w[qmd --version]),
+          *AUTH_PROBES.flat_map { |name, probe| agent_results(name, probe) },
+          web_bundle_result
+        ]
+      end
+
+      private
+
+      def platform_result
+        supported = @host_os.match?(/linux|darwin/i)
+        result(
+          "platform", "platform",
+          detected: @host_os,
+          required: "Linux or macOS",
+          status: supported ? "pass" : "fail",
+          remediation: supported ? [] : [ "Run Hive local web on Linux or macOS; Windows is not supported." ]
+        )
+      end
+
+      def ruby_result
+        supported = Gem::Version.new(@ruby_version) >= Gem::Version.new("3.4")
+        result(
+          "ruby", "runtime",
+          detected: @ruby_version,
+          required: ">= 3.4",
+          status: supported ? "pass" : "fail",
+          remediation: supported ? [] : [ ruby_hint ]
+        )
+      rescue ArgumentError => e
+        result("ruby", "runtime", detected: @ruby_version, required: ">= 3.4",
+               status: "fail", remediation: [ ruby_hint ], message: e.message)
+      end
+
+      def command_result(name, category:, remediation:, bootstrappable: false, version_argv: nil)
+        path = @which.call(name)
+        unless path
+          return result(name, category, detected: nil, required: "installed",
+                        status: "missing", remediation: [ remediation ],
+                        bootstrappable: bootstrappable)
+        end
+
+        probe = version_argv && safe_probe(version_argv)
+        if probe && !probe[:ok]
+          return result(name, category, detected: path, required: "working",
+                        status: "fail", remediation: [ remediation ],
+                        bootstrappable: bootstrappable, message: probe_message(probe))
+        end
+
+        detected = probe ? first_line(probe[:stdout], probe[:stderr]) : path
+        result(name, category, detected: detected, required: "installed",
+               status: "pass", remediation: [], bootstrappable: bootstrappable)
+      end
+
+      def agent_results(name, probe)
+        command = command_result(
+          name,
+          category: "agent",
+          remediation: install_hint(name)
+        )
+        return [ command ] unless command.success?
+
+        auth = safe_probe(probe.fetch(:argv))
+        auth_result =
+          if auth[:ok]
+            result("#{name}_auth", "authentication", detected: "authenticated",
+                   required: "authenticated", status: "pass", remediation: [])
+          else
+            result("#{name}_auth", "authentication", detected: "unauthenticated",
+                   required: "authenticated", status: "fail",
+                   remediation: [ probe.fetch(:remediation) ],
+                   message: probe_message(auth))
+          end
+        [ command, auth_result ]
+      end
+
+      def web_bundle_result
+        current = @web_bundle_current.call
+        result(
+          "web_bundle", "hive_owned",
+          detected: current ? Hive::VERSION : nil,
+          required: Hive::VERSION,
+          status: current ? "pass" : "missing",
+          remediation: current ? [] : [ "hive web install" ],
+          bootstrappable: true
+        )
+      rescue StandardError => e
+        result("web_bundle", "hive_owned", detected: nil, required: Hive::VERSION,
+               status: "fail", remediation: [ "hive web install" ],
+               bootstrappable: true, message: e.message)
+      end
+
+      def cosign_result
+        if custom_bundle_authenticated?
+          return result(
+            "cosign", "security", detected: "not required (custom SHA-256)",
+            required: "cosign or custom bundle SHA-256", status: "pass", remediation: []
+          )
+        end
+
+        command_result("cosign", category: "security", remediation: install_hint("cosign"))
+      end
+
+      def custom_bundle_authenticated?
+        !ENV["HIVE_WEB_BUNDLE_URL"].to_s.empty? &&
+          ENV["HIVE_WEB_BUNDLE_SHA256"].to_s.match?(/\A[0-9a-f]{64}\z/i)
+      end
+
+      def safe_probe(argv)
+        @runner.call(argv, timeout: @timeout)
+      rescue Timeout::Error
+        { ok: false, stdout: "", stderr: "timed out after #{@timeout}s", exit_code: nil }
+      rescue SystemCallError => e
+        { ok: false, stdout: "", stderr: e.message, exit_code: nil }
+      rescue StandardError => e
+        { ok: false, stdout: "", stderr: "#{e.class}: #{e.message}", exit_code: nil }
+      end
+
+      def run_probe(argv, timeout:)
+        stdout, stderr, status = Hive::BoundedProcess.capture3(*argv, timeout: timeout)
+        { ok: status.success?, stdout: stdout, stderr: stderr, exit_code: status.exitstatus }
+      end
+
+      def which(name)
+        search_paths = [
+          Hive::Paths.bin_home,
+          File.join(Hive::Paths.data_home, "qmd", "bin"),
+          *ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
+        ].uniq
+        search_paths.each do |dir|
+          path = File.join(dir, name)
+          return path if File.file?(path) && File.executable?(path)
+        end
+        nil
+      end
+
+      def web_bundle_current?
+        marker = File.join(Hive::Paths.data_home, "web", ".hive-web-version")
+        File.file?(marker) && File.read(marker).strip == Hive::VERSION
+      end
+
+      def result(name, category, detected:, required:, status:, remediation:,
+                 bootstrappable: false, message: nil)
+        CheckResult.new(
+          name: name, category: category, detected: detected, required: required,
+          status: status, remediation: remediation, bootstrappable: bootstrappable,
+          message: message
+        )
+      end
+
+      def first_line(*values)
+        values.find { |value| !value.to_s.strip.empty? }.to_s.lines.first.to_s.strip
+      end
+
+      def probe_message(probe)
+        first_line(probe[:stderr], probe[:stdout], "exit #{probe[:exit_code]}")
+      end
+
+      def install_hint(tool)
+        return "brew install #{tool}" if @host_os.match?(/darwin/i)
+
+        linux_install_hint(tool)
+      end
+
+      def node_hint
+        @host_os.match?(/darwin/i) ? "brew install node" : "sudo apt-get update && sudo apt-get install -y nodejs npm"
+      end
+
+      def ruby_hint
+        return "brew install ruby@3.4" if @host_os.match?(/darwin/i)
+
+        "Install Ruby 3.4 with `mise use --global ruby@3.4`"
+      end
+
+      def linux_install_hint(tool)
+        package = { "sqlite3" => "sqlite3", "claude" => "@anthropic-ai/claude-code",
+                    "codex" => "@openai/codex" }.fetch(tool, tool)
+        return "npm install --global #{package}" if %w[claude codex].include?(tool)
+        return "See https://cli.github.com/manual/installation for Linux installation commands" if tool == "gh"
+        return "Install cosign from https://docs.sigstore.dev/cosign/system_config/installation/" if tool == "cosign"
+
+        "sudo apt-get update && sudo apt-get install -y #{package}"
+      end
+    end
+  end
+end
diff --git a/lib/hive/skill_check.rb b/lib/hive/skill_check.rb
index e11197d7..5bb1b8de 100644
--- a/lib/hive/skill_check.rb
+++ b/lib/hive/skill_check.rb
@@ -2,6 +2,7 @@ require "json"
 require "open3"
 require "pathname"
 require "timeout"
+require "hive/bounded_process"
 
 module Hive
   # Per-agent verification that a configured slash-command skill
@@ -366,9 +367,9 @@ module Hive
       end
 
       def global_npm_root
-        out, _err, status = Timeout.timeout(NPM_ROOT_TIMEOUT_SEC) do
-          Open3.capture3("npm", "root", "-g")
-        end
+        out, _err, status = Hive::BoundedProcess.capture3(
+          "npm", "root", "-g", timeout: NPM_ROOT_TIMEOUT_SEC
+        )
         return nil unless status.success?
 
         root = out.lines.first&.strip
diff --git a/lib/hive/stages/auto_commit.rb b/lib/hive/stages/auto_commit.rb
index 780c121c..d4e5df82 100644
--- a/lib/hive/stages/auto_commit.rb
+++ b/lib/hive/stages/auto_commit.rb
@@ -1,6 +1,7 @@
 require "open3"
 require "fileutils"
 require "hive/config"
+require "hive/bounded_process"
 require "hive/git_ops"
 
 module Hive
@@ -59,16 +60,11 @@ module Hive
       # callers (CleanExit etc.) surface `:error reason=
       # ensure_clean_on_exit_failed`.
       #
-      # Wraps `Open3.capture3` in a `Timeout.timeout` guard. When a
-      # test stubs `Open3.capture3` (current_main_coverage_gap_test.rb
-      # et al.), the stub fires inside the timeout block and returns
-      # immediately, preserving the existing stub-based test surface.
-      # The real call benefits from the bounded deadline: a hung child
-      # produces `Timeout::Error`, which we translate to
-      # `timed_out: true`.
+      # The bounded runner owns a process group and terminates/reaps it on
+      # timeout; Timeout.timeout around Open3.capture3 can remain blocked in
+      # Open3 cleanup while the child is still alive.
       def capture_git_with_timeout(argv, label:, timeout_sec: AUTO_COMMIT_OP_TIMEOUT_SEC)
-        require "timeout"
-        out, err, status = Timeout.timeout(timeout_sec) { Open3.capture3(*argv) }
+        out, err, status = Hive::BoundedProcess.capture3(*argv, timeout: timeout_sec)
         if status.success?
           { success: true, timed_out: false, stdout: out.to_s, stderr: err.to_s }
         else
@@ -84,7 +80,7 @@ module Hive
         {
           success: false,
           timed_out: true,
-          message: "#{label} timed out after #{AUTO_COMMIT_OP_TIMEOUT_SEC}s",
+          message: "#{label} timed out after #{timeout_sec}s",
           stdout: "",
           stderr: ""
         }
diff --git a/lib/hive/web/app_bundle.rb b/lib/hive/web/app_bundle.rb
new file mode 100644
index 00000000..44866144
--- /dev/null
+++ b/lib/hive/web/app_bundle.rb
@@ -0,0 +1,264 @@
+require "digest"
+require "fileutils"
+require "open-uri"
+require "openssl"
+require "securerandom"
+require "timeout"
+require "tmpdir"
+require "hive"
+require "hive/bounded_process"
+require "hive/paths"
+require "hive/web/archive_validator"
+
+module Hive
+  module Web
+    class AppBundle
+      DOWNLOAD_OPEN_TIMEOUT = 10
+      DOWNLOAD_READ_TIMEOUT = 30
+      DOWNLOAD_TOTAL_TIMEOUT = 120
+      PREPARE_TIMEOUT = 600
+
+      attr_reader :version, :url
+
+      def initialize(version: Hive::VERSION, url: nil, sha256: nil,
+                     downloader: nil, runner: nil, cli_root: nil, quiet: false)
+        @version = version
+        @default_url = release_url("hive-web-#{version}.tar.gz")
+        url ||= ENV["HIVE_WEB_BUNDLE_URL"]
+        sha256 ||= ENV["HIVE_WEB_BUNDLE_SHA256"]
+        @url = url || @default_url
+        @sha256 = sha256
+        if url && sha256.to_s.empty?
+          raise Hive::Error, "hive web: a custom bundle URL requires an explicit matching SHA-256"
+        end
+        unless @sha256.nil? || @sha256.match?(/\A[0-9a-f]{64}\z/i)
+          raise Hive::Error, "hive web: bundle SHA-256 must be exactly 64 hexadecimal characters"
+        end
+
+        @downloader = downloader || method(:download)
+        @runner = runner || method(:run)
+        @cli_root = cli_root || installed_cli_root
+        @quiet = quiet
+      end
+
+      def current?
+        active = Hive::Paths.web_app_home
+        File.file?(version_marker) &&
+          File.read(version_marker).strip == version &&
+          File.file?(File.join(active, "Gemfile")) &&
+          File.file?(File.join(active, "Gemfile.lock")) &&
+          File.file?(File.join(active, "config", "application.rb")) &&
+          File.executable?(File.join(active, "bin", "rails"))
+      rescue SystemCallError
+        false
+      end
+
+      def install!
+        return Hive::Paths.web_app_home if current?
+
+        FileUtils.mkdir_p(Hive::Paths.data_home)
+        File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
+          lock.flock(File::LOCK_EX)
+          return Hive::Paths.web_app_home if current?
+
+          install_locked!
+        end
+      end
+
+      private
+
+      def install_locked!
+        stage_root = Dir.mktmpdir(".hive-web-stage-", Hive::Paths.data_home)
+        archive = File.join(stage_root, "bundle.tar.gz")
+        extracted = File.join(stage_root, "app")
+        @downloader.call(url, archive)
+        expected = @sha256 || trusted_release_sha(stage_root)
+        actual = ::Digest::SHA256.file(archive).hexdigest
+        unless secure_equal?(expected.downcase, actual.downcase)
+          raise Hive::Error,
+                "hive web: checksum mismatch for #{File.basename(archive)} " \
+                "(expected #{expected}, got #{actual})"
+        end
+
+        ArchiveValidator.new(archive).extract_to(extracted)
+        validate_embedded_version!(extracted)
+        prepare!(extracted)
+        activate!(extracted)
+        Hive::Paths.web_app_home
+      rescue Hive::Error
+        raise
+      rescue StandardError => e
+        raise Hive::Error, "hive web: bundle installation failed: #{e.message}"
+      ensure
+        FileUtils.rm_rf(stage_root) if stage_root
+      end
+
+      def trusted_release_sha(stage_root)
+        sums = File.join(stage_root, "SHA256SUMS")
+        @downloader.call(release_url("SHA256SUMS"), sums)
+        verify_signature!(stage_root, sums)
+        asset = "hive-web-#{version}.tar.gz"
+        line = File.readlines(sums, chomp: true).find do |candidate|
+          candidate.match?(/\A[0-9a-f]{64}\s+\*?(?:\.\/)?#{Regexp.escape(asset)}\z/i)
+        end
+        raise Hive::Error, "hive web: release checksums do not contain #{asset}" unless line
+
+        line.split(/\s+/, 2).first
+      end
+
+      def verify_signature!(stage_root, sums)
+        unless executable_on_path?("cosign")
+          raise Hive::Error,
+                "hive web: cosign is required to authenticate the default release bundle; " \
+                "install cosign or use HIVE_WEB_BUNDLE_URL with HIVE_WEB_BUNDLE_SHA256"
+        end
+
+        signature = File.join(stage_root, "SHA256SUMS.sig")
+        certificate = File.join(stage_root, "SHA256SUMS.pem")
+        @downloader.call(release_url("SHA256SUMS.sig"), signature)
+        @downloader.call(release_url("SHA256SUMS.pem"), certificate)
+        identity = "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/.github/workflows/release.yml@refs/tags/v#{version}"
+        argv = [
+          "cosign", "verify-blob",
+          "--certificate", certificate,
+          "--signature", signature,
+          "--certificate-identity", identity,
+          "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
+          sums
+        ]
+        return if @runner.call({}, argv, chdir: stage_root)
+
+        raise Hive::Error, "hive web: cosign verification failed for release checksums"
+      end
+
+      def validate_embedded_version!(directory)
+        marker = File.join(directory, ".hive-web-version")
+        embedded = File.file?(marker) ? File.read(marker).strip : nil
+        return if embedded == version
+
+        raise Hive::Error,
+              "hive web: bundle version mismatch (expected #{version}, found #{embedded.inspect})"
+      end
+
+      def prepare!(directory)
+        FileUtils.mkdir_p(Hive::Paths.web_gems_home)
+        FileUtils.mkdir_p(Hive::Paths.web_storage_home)
+        seed_matching_default_gems!(directory)
+        env = {
+          "RAILS_ENV" => "production",
+          "BUNDLE_GEMFILE" => File.join(directory, "Gemfile"),
+          "BUNDLE_PATH" => Hive::Paths.web_gems_home,
+          "BUNDLE_WITHOUT" => "development:test",
+          "SECRET_KEY_BASE_DUMMY" => "1",
+          "HIVE_CLI_ROOT" => @cli_root,
+          "HIVE_WEB_STORAGE_DIR" => Hive::Paths.web_storage_home,
+          "HIVEBOX_STORAGE_DIR" => Hive::Paths.web_storage_home
+        }
+        commands = [
+          [ "bundle", "install" ],
+          [ File.join(directory, "bin", "rails"), "assets:precompile" ],
+          [ File.join(directory, "bin", "rails"), "db:prepare" ]
+        ]
+        commands.each do |argv|
+          next if @runner.call(env, argv, chdir: directory)
+
+          raise Hive::Error, "hive web: preparation failed: #{argv.join(' ')}"
+        end
+      end
+
+      # Bundler intentionally refuses to mix a custom BUNDLE_PATH with shared
+      # gems. Re-extracting a matching Ruby default gem can require development
+      # headers that a normal runtime installation does not retain (notably
+      # libyaml for psych). Link exact lockfile matches into the managed bundle
+      # path so the dependency remains version-pinned without recompilation.
+      def seed_matching_default_gems!(directory)
+        lock = File.read(File.join(directory, "Gemfile.lock"))
+        root = File.join(Hive::Paths.web_gems_home, "ruby", Gem.ruby_api_version)
+        gems = File.join(root, "gems")
+        specifications = File.join(root, "specifications")
+        FileUtils.mkdir_p([ gems, specifications ])
+
+        Gem::Specification.select(&:default_gem?).each do |spec|
+          next unless lock.match?(/^\s{4}#{Regexp.escape(spec.name)} \(#{Regexp.escape(spec.version.to_s)}\)/)
+
+          gem_link = File.join(gems, File.basename(spec.full_gem_path))
+          spec_link = File.join(specifications, File.basename(spec.loaded_from))
+          link_default_gem(spec.full_gem_path, gem_link)
+          link_default_gem(spec.loaded_from, spec_link)
+        end
+      end
+
+      def link_default_gem(source, target)
+        FileUtils.rm_f(target) if File.symlink?(target) && !File.exist?(target)
+        FileUtils.ln_s(source, target) unless File.exist?(target) || File.symlink?(target)
+      end
+
+      def activate!(staged)
+        active = Hive::Paths.web_app_home
+        previous = "#{active}.previous-#{Process.pid}-#{SecureRandom.hex(4)}"
+        File.rename(active, previous) if File.exist?(active)
+        begin
+          File.rename(staged, active)
+          FileUtils.rm_rf(previous)
+        rescue StandardError
+          FileUtils.rm_rf(active)
+          File.rename(previous, active) if File.exist?(previous)
+          raise
+        end
+      end
+
+      def installed_cli_root
+        spec = Gem.loaded_specs["hive-cli"]
+        return spec.full_gem_path if spec
+
+        File.expand_path("../../..", __dir__)
+      end
+
+      def release_url(asset)
+        "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/releases/download/v#{version}/#{asset}"
+      end
+
+      def download(source, target)
+        Timeout.timeout(DOWNLOAD_TOTAL_TIMEOUT) do
+          URI.open(
+            source,
+            "rb",
+            open_timeout: DOWNLOAD_OPEN_TIMEOUT,
+            read_timeout: DOWNLOAD_READ_TIMEOUT
+          ) do |input|
+            File.open(target, "wb", 0o600) { |output| IO.copy_stream(input, output) }
+          end
+        end
+      end
+
+      def run(env, argv, chdir:)
+        output = @quiet ? File::NULL : $stdout
+        Hive::BoundedProcess.run(
+          env, *argv, chdir: chdir, timeout: PREPARE_TIMEOUT,
+          out: output, err: output
+        )
+      end
+
+      def executable_on_path?(name)
+        ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |directory|
+          path = File.join(directory, name)
+          File.file?(path) && File.executable?(path)
+        end
+      end
+
+      def secure_equal?(left, right)
+        return false unless left.bytesize == right.bytesize
+
+        OpenSSL.fixed_length_secure_compare(left, right)
+      end
+
+      def version_marker
+        File.join(Hive::Paths.web_app_home, ".hive-web-version")
+      end
+
+      def lock_path
+        File.join(Hive::Paths.data_home, ".web-install.lock")
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/archive_validator.rb b/lib/hive/web/archive_validator.rb
new file mode 100644
index 00000000..51791ee5
--- /dev/null
+++ b/lib/hive/web/archive_validator.rb
@@ -0,0 +1,94 @@
+require "fileutils"
+require "pathname"
+require "rubygems/package"
+require "zlib"
+require "hive"
+
+module Hive
+  module Web
+    class ArchiveValidator
+      FILE_TYPES = [ "0", "\0" ].freeze
+      DIRECTORY_TYPE = "5"
+      UNSAFE_MODE_MASK = 0o7022
+
+      def initialize(path)
+        @path = path
+      end
+
+      def extract_to(destination)
+        FileUtils.mkdir_p(destination)
+        each_entry do |entry|
+          relative = safe_path(entry.full_name)
+          validate_type!(entry)
+          validate_mode!(entry)
+          if relative == "."
+            unless entry.header.typeflag == DIRECTORY_TYPE
+              raise Hive::Error, "hive web: archive root entry must be a directory"
+            end
+            next
+          end
+          target = File.join(destination, relative)
+          ensure_inside!(destination, target)
+
+          if entry.header.typeflag == DIRECTORY_TYPE
+            FileUtils.mkdir_p(target)
+            File.chmod(entry.header.mode & 0o777, target)
+          else
+            FileUtils.mkdir_p(File.dirname(target))
+            File.open(target, File::WRONLY | File::CREAT | File::EXCL, entry.header.mode & 0o777) do |io|
+              IO.copy_stream(entry, io)
+            end
+          end
+        end
+        destination
+      rescue Gem::Package::TarInvalidError, Zlib::GzipFile::Error, EOFError => e
+        raise Hive::Error, "hive web: invalid or truncated web archive: #{e.message}"
+      end
+
+      private
+
+      def each_entry
+        Zlib::GzipReader.open(@path) do |gzip|
+          Gem::Package::TarReader.new(gzip) do |tar|
+            tar.each { |entry| yield entry }
+          end
+        end
+      end
+
+      def safe_path(name)
+        raw = name.to_s
+        clean = Pathname.new(raw).cleanpath.to_s
+        if raw.empty? || raw.start_with?("/", "\\") || raw.include?("\0") ||
+           clean == ".." || clean.start_with?("../")
+          raise Hive::Error, "hive web: unsafe archive path #{raw.inspect}"
+        end
+        clean
+      end
+
+      def validate_type!(entry)
+        type = entry.header.typeflag
+        return if FILE_TYPES.include?(type) || type == DIRECTORY_TYPE
+
+        raise Hive::Error,
+              "hive web: archive entry #{entry.full_name.inspect} has unsupported type #{type.inspect} " \
+              "(links, devices, and FIFOs are forbidden)"
+      end
+
+      def validate_mode!(entry)
+        mode = entry.header.mode
+        return if (mode & UNSAFE_MODE_MASK).zero?
+
+        raise Hive::Error,
+              "hive web: archive entry #{entry.full_name.inspect} has unsafe mode #{format('%o', mode)}"
+      end
+
+      def ensure_inside!(root, target)
+        expanded_root = "#{File.expand_path(root)}#{File::SEPARATOR}"
+        expanded_target = File.expand_path(target)
+        return if expanded_target.start_with?(expanded_root)
+
+        raise Hive::Error, "hive web: archive entry escapes the installation directory"
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/daemon_maintenance.rb b/lib/hive/web/daemon_maintenance.rb
new file mode 100644
index 00000000..f33b39fd
--- /dev/null
+++ b/lib/hive/web/daemon_maintenance.rb
@@ -0,0 +1,134 @@
+require "json"
+require "fileutils"
+require "time"
+require "hive"
+require "hive/paths"
+require "hive/commands/service_installer/outcome"
+
+module Hive
+  module Web
+    class DaemonMaintenance
+      ACTIONS = %w[repair restart].freeze
+
+      def initialize(installer:, active_tasks: nil)
+        @installer = installer
+        @active_tasks = active_tasks || method(:current_active_tasks)
+      end
+
+      def call(action)
+        unless ACTIONS.include?(action)
+          raise Hive::InvalidTaskPath,
+                "unknown daemon maintenance action #{action.inspect}"
+        end
+
+        active = Array(@active_tasks.call)
+        if active.any?
+          result = maintenance_result(
+            action,
+            ok: false,
+            message: "refusing daemon #{action}: #{active.length} active agent " \
+                     "#{active.length == 1 ? 'task' : 'tasks'} would be interrupted",
+            refused: true,
+            active_tasks: active
+          )
+          persist(result)
+          return result
+        end
+
+        ok, message = perform(action)
+        result = {
+          "action" => action, "ok" => ok, "message" => message,
+          "completed_at" => Time.now.utc.iso8601,
+          "refused" => false,
+          "active_tasks" => [],
+          "forced_interruptions" => 0
+        }
+        persist(result)
+        result
+      rescue Hive::InvalidTaskPath
+        raise
+      rescue StandardError => e
+        result = {
+          "action" => action.to_s, "ok" => false,
+          "message" => "#{e.class}: #{e.message}", "completed_at" => Time.now.utc.iso8601,
+          "refused" => true,
+          "active_tasks" => [],
+          "forced_interruptions" => 0
+        }
+        persist(result)
+        result
+      end
+
+      def self.last_result
+        path = result_path
+        File.file?(path) ? JSON.parse(File.read(path)) : nil
+      rescue JSON::ParserError, SystemCallError
+        nil
+      end
+
+      def self.result_path
+        File.join(Hive::Paths.state_home, ".daemon-maintenance.json")
+      end
+
+      private
+
+      def perform(action)
+        case action
+        when "repair"
+          outcome = @installer.install!(autostart: true, force: true)
+          [ outcome.success?, "daemon service #{outcome.wire_outcome}; no active agents were interrupted" ]
+        when "restart"
+          stopped = @installer.stop!
+          started = stopped && @installer.start!
+          [
+            !!started,
+            started ? "daemon restarted; no active agents were interrupted" : "daemon restart failed"
+          ]
+        end
+      end
+
+      def maintenance_result(action, ok:, message:, refused:, active_tasks:)
+        {
+          "action" => action,
+          "ok" => ok,
+          "message" => message,
+          "completed_at" => Time.now.utc.iso8601,
+          "refused" => refused,
+          "active_tasks" => active_tasks,
+          "forced_interruptions" => 0
+        }
+      end
+
+      def current_active_tasks
+        require "hive/commands/status"
+        payload = Hive::Commands::Status.new(json: true).json_payload(
+          Hive::Config.registered_projects
+        )
+        payload.fetch("projects").flat_map do |project|
+          project.fetch("tasks", []).filter_map do |task|
+            next unless task["action"] == "agent_running"
+
+            {
+              "project" => project.fetch("name"),
+              "slug" => task.fetch("slug"),
+              "stage" => task.fetch("stage")
+            }
+          end
+        end
+      rescue StandardError => e
+        raise Hive::Error,
+              "cannot verify active agents before daemon maintenance: #{e.class}: #{e.message}"
+      end
+
+      def persist(result)
+        path = self.class.result_path
+        FileUtils.mkdir_p(File.dirname(path))
+        temp = "#{path}.tmp.#{Process.pid}"
+        File.write(temp, JSON.generate(result), mode: "w", perm: 0o600)
+        File.rename(temp, path)
+      ensure
+        FileUtils.rm_f(temp) if temp && File.exist?(temp)
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/environment.rb b/lib/hive/web/environment.rb
new file mode 100644
index 00000000..661bf1a7
--- /dev/null
+++ b/lib/hive/web/environment.rb
@@ -0,0 +1,102 @@
+require "hive"
+require "hive/paths"
+require "hive/web/loopback"
+
+module Hive
+  module Web
+    class Environment
+      attr_reader :bind, :port, :unsafe
+
+      def initialize(config:, bind: nil, port: nil, unsafe: false, env: ENV)
+        @config = config
+        @env = env
+        @bind = first_present(bind, env["HIVE_WEB_BIND"], env["HIVEBOX_BIND"], config["bind"])
+        @port = Integer(first_present(port, env["HIVE_WEB_PORT"], env["HIVEBOX_PORT"], config["port"]))
+        raise Hive::Error, "hive web: port must be between 1 and 65535" unless (1..65_535).cover?(@port)
+
+        @unsafe = unsafe || truthy?(env["HIVE_WEB_UNSAFE"]) || truthy?(env["HIVEBOX_UNSAFE"])
+      rescue ArgumentError, TypeError
+        raise Hive::Error, "hive web: invalid port #{port || env['HIVE_WEB_PORT'] || env['HIVEBOX_PORT']}"
+      end
+
+      def loopback?
+        Loopback.address?(bind)
+      end
+
+      def authentication_configured?
+        !@config.dig("github", "client_id").to_s.strip.empty?
+      end
+
+      def validate_security!
+        return true if loopback? || authentication_configured?
+
+        if unsafe
+          warn "hive web: UNSAFE non-loopback exposure enabled for #{bind}:#{port}; authentication is not configured"
+          return true
+        end
+
+        raise Hive::Error,
+              "hive web: refusing non-loopback bind #{bind.inspect} without a configured GitHub auth flow. " \
+              "Configure web.github.client_id or pass --unsafe to acknowledge unauthenticated exposure."
+      end
+
+      def url
+        host = bind == "::1" ? "[::1]" : bind
+        "http://#{host}:#{port}"
+      end
+
+      def to_h(app_dir:, cli_root:, managed: false)
+        storage = first_present(
+          @env["HIVE_WEB_STORAGE_DIR"],
+          @env["HIVEBOX_STORAGE_DIR"]
+        )
+        storage ||= Hive::Paths.web_storage_home if managed
+        origin = first_present(@env["HIVE_WEB_ORIGIN"], @env["HIVEBOX_ORIGIN"], @config["origin"], url)
+        environment = {
+          "HIVE_WEB_BIND" => bind,
+          "HIVE_WEB_PORT" => port.to_s,
+          "HIVE_WEB_ORIGIN" => origin,
+          "HIVE_WEB_LOCAL_MODE" => loopback? ? "true" : "false",
+          "HIVE_WEB_UNSAFE" => unsafe ? "true" : "false",
+          "HIVE_CONFIG_HOME" => Hive::Paths.config_home,
+          "HIVE_DATA_HOME" => Hive::Paths.data_home,
+          "HIVE_STATE_HOME" => Hive::Paths.state_home,
+          "HIVE_CACHE_HOME" => Hive::Paths.cache_home,
+          "HIVE_CLI_ROOT" => cli_root,
+          "BUNDLE_GEMFILE" => File.join(app_dir, "Gemfile"),
+          # Docker/source compatibility aliases. Canonical names always win
+          # because their values were resolved before these are constructed.
+          "HIVEBOX_ORIGIN" => origin
+        }.merge(xdg_environment)
+        if storage
+          environment["HIVE_WEB_STORAGE_DIR"] = storage
+          environment["HIVEBOX_STORAGE_DIR"] = storage
+        end
+        if managed
+          environment["BUNDLE_PATH"] = Hive::Paths.web_gems_home
+          environment["BUNDLE_WITHOUT"] = "development:test"
+        end
+        environment
+      end
+
+      private
+
+      def xdg_environment
+        {
+          "XDG_CONFIG_HOME" => @env["XDG_CONFIG_HOME"] || File.dirname(Hive::Paths.config_home),
+          "XDG_DATA_HOME" => @env["XDG_DATA_HOME"] || File.dirname(Hive::Paths.data_home),
+          "XDG_STATE_HOME" => @env["XDG_STATE_HOME"] || File.dirname(Hive::Paths.state_home),
+          "XDG_CACHE_HOME" => @env["XDG_CACHE_HOME"] || File.dirname(Hive::Paths.cache_home)
+        }
+      end
+
+      def first_present(*values)
+        values.find { |value| !value.nil? && !value.to_s.empty? }
+      end
+
+      def truthy?(value)
+        %w[1 true yes on].include?(value.to_s.downcase)
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/host_authorization.rb b/lib/hive/web/host_authorization.rb
new file mode 100644
index 00000000..ea3c50e6
--- /dev/null
+++ b/lib/hive/web/host_authorization.rb
@@ -0,0 +1,38 @@
+require "ipaddr"
+require "uri"
+
+module Hive
+  module Web
+    module HostAuthorization
+      module_function
+
+      def allowed_hosts(bind:, origin: nil)
+        [
+          "localhost",
+          IPAddr.new("127.0.0.0/8"),
+          IPAddr.new("::1"),
+          bind_host(bind),
+          origin_host(origin)
+        ].compact.uniq
+      end
+
+      def bind_host(bind)
+        case bind.to_s
+        when "0.0.0.0" then IPAddr.new("0.0.0.0/0")
+        when "::" then IPAddr.new("::/0")
+        else bind
+        end
+      end
+      private_class_method :bind_host
+
+      def origin_host(origin)
+        return if origin.to_s.empty?
+
+        URI(origin).host
+      rescue URI::InvalidURIError
+        nil
+      end
+      private_class_method :origin_host
+    end
+  end
+end
diff --git a/lib/hive/web/loopback.rb b/lib/hive/web/loopback.rb
new file mode 100644
index 00000000..1b145a72
--- /dev/null
+++ b/lib/hive/web/loopback.rb
@@ -0,0 +1,18 @@
+require "ipaddr"
+
+module Hive
+  module Web
+    module Loopback
+      module_function
+
+      def address?(value)
+        text = value.to_s.strip
+        return true if text.casecmp("localhost").zero?
+
+        IPAddr.new(text).loopback?
+      rescue IPAddr::InvalidAddressError
+        false
+      end
+    end
+  end
+end
diff --git a/lib/hive/web/service_status.rb b/lib/hive/web/service_status.rb
new file mode 100644
index 00000000..8274c4f6
--- /dev/null
+++ b/lib/hive/web/service_status.rb
@@ -0,0 +1,72 @@
+require "net/http"
+require "uri"
+require "socket"
+
+module Hive
+  module Web
+    class ServiceStatus
+      def initialize(installer:, url:, http_get: nil, port_in_use: nil)
+        @installer = installer
+        @desired_url = url
+        @http_get = http_get || method(:healthy?)
+        @port_in_use = port_in_use || method(:port_in_use?)
+      end
+
+      def to_h
+        service = @installer.service_state
+        installed = @installer.installed_settings
+        url = installed["url"] || @desired_url
+        manager_available = @installer.service_manager_available?
+        running = @installer.running?
+        health_ready = running && @http_get.call(URI("#{url}/health"))
+        configuration_drift = installed["configuration_drift"] == true
+        ready = health_ready && !configuration_drift
+        failure =
+          if !manager_available then "service_manager_unavailable"
+          elsif !service["service_installed"] then "not_installed"
+          elsif !installed["readable"] then "unit_unreadable"
+          elsif !service["service_enabled"] then "not_enabled"
+          elsif !running && @port_in_use.call(installed["bind"], installed["port"]) then "port_conflict"
+          elsif !running then "process_not_running"
+          elsif !health_ready then "health_failed"
+          elsif configuration_drift then "configuration_drift"
+          end
+        service.merge(
+          "running" => running,
+          "ready" => ready,
+          "url" => url,
+          "desired_url" => @desired_url,
+          "configuration_drift" => configuration_drift,
+          "unsafe" => installed["unsafe"] == true,
+          "failure" => failure,
+          "message" => installed["message"]
+        )
+      rescue StandardError => e
+        {
+          "platform" => nil, "unit_path" => nil, "service_installed" => nil,
+          "service_enabled" => nil, "running" => false, "ready" => false,
+          "url" => @desired_url, "desired_url" => @desired_url,
+          "configuration_drift" => false, "unsafe" => false,
+          "failure" => "probe_failed", "message" => e.message
+        }
+      end
+
+      private
+
+      def healthy?(uri)
+        response = Net::HTTP.start(
+          uri.host, uri.port, open_timeout: 1, read_timeout: 2
+        ) { |http| http.get(uri.request_uri) }
+        response.is_a?(Net::HTTPSuccess)
+      rescue SystemCallError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout
+        false
+      end
+
+      def port_in_use?(bind, port)
+        Socket.tcp(bind, port, connect_timeout: 0.2) { true }
+      rescue SystemCallError, SocketError, Timeout::Error
+        false
+      end
+    end
+  end
+end
diff --git a/openclaw/skills/hive/SKILL.md b/openclaw/skills/hive/SKILL.md
index 4eecc409..05da7653 100644
--- a/openclaw/skills/hive/SKILL.md
+++ b/openclaw/skills/hive/SKILL.md
@@ -41,11 +41,14 @@ 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` runs first-class local setup: diagnostics, Hive-owned QMD/web
+  bundle bootstrap, repository enrollment, and separate daemon/web user
+  services ready at `http://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 local web in the foreground; `/hive web
+  install|start|stop|status` manages its independent user service.
 - `/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.
 
@@ -81,7 +84,16 @@ 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. With confirmation, run
+`"${hive_cmd}" setup --json` from the repository to enroll. Report every failed
+diagnostic/phase exactly; do not install or authenticate external prerequisites
+on the user's behalf. On success, include daemon drift/readiness and
+`"${hive_cmd}" web status --json`, with the default local URL.
+
+If setup was deliberately run with `--no-init`, the confirmed manual enrollment
+fallback remains `"${hive_cmd}" init . --json </dev/null`.
 
 ## Dispatch Rules
 
diff --git a/packaging/smoke-local-web.sh b/packaging/smoke-local-web.sh
new file mode 100755
index 00000000..454560b5
--- /dev/null
+++ b/packaging/smoke-local-web.sh
@@ -0,0 +1,190 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+HOME_BEFORE="${HOME:?HOME must be set}"
+GEM_FILE="${1:-}"
+WEB_ARCHIVE="${2:-}"
+SANDBOX="$(mktemp -d -t hive-local-web-smoke-XXXXXX)"
+WEB_PID=""
+MANAGED_SERVICE_INSTALLED=0
+SERVICE_HOME=""
+
+cleanup() {
+  if [[ -n "$WEB_PID" ]] && kill -0 "$WEB_PID" 2>/dev/null; then
+    kill "$WEB_PID" 2>/dev/null || true
+    wait "$WEB_PID" 2>/dev/null || true
+  fi
+  if [[ "$MANAGED_SERVICE_INSTALLED" -eq 1 ]] && [[ -n "$SERVICE_HOME" ]]; then
+    HOME="$SERVICE_HOME" "$HIVE_BIN" uninstall --purge >/dev/null 2>&1 || true
+  fi
+  rm -rf "$SANDBOX"
+}
+trap cleanup EXIT
+
+if [[ -z "$GEM_FILE" ]]; then
+  (cd "$REPO_ROOT" && gem build hive.gemspec --output "$SANDBOX/hive-cli.gem")
+  GEM_FILE="$SANDBOX/hive-cli.gem"
+fi
+
+GEM_FILE="$(cd "$(dirname "$GEM_FILE")" && pwd)/$(basename "$GEM_FILE")"
+GEM_HOME="$SANDBOX/gems"
+DEFAULT_GEM_PATH="$(ruby -e 'puts Gem.default_path.join(File::PATH_SEPARATOR)')"
+export GEM_HOME GEM_PATH="$GEM_HOME:$DEFAULT_GEM_PATH"
+gem install "$GEM_FILE" --install-dir "$GEM_HOME" --bindir "$GEM_HOME/bin" --no-document
+HIVE_BIN="$GEM_HOME/bin/hive"
+VERSION="$("$HIVE_BIN" --version)"
+
+if [[ -z "$WEB_ARCHIVE" ]]; then
+  WEB_STAGE="$SANDBOX/web-stage"
+  mkdir -p "$WEB_STAGE"
+  cp -R "$REPO_ROOT/web/app" "$REPO_ROOT/web/bin" "$REPO_ROOT/web/config" \
+    "$REPO_ROOT/web/db" "$REPO_ROOT/web/public" "$REPO_ROOT/web/config.ru" \
+    "$REPO_ROOT/web/Gemfile" "$REPO_ROOT/web/Gemfile.lock" \
+    "$REPO_ROOT/web/Rakefile" "$WEB_STAGE/"
+  printf '%s\n' "$VERSION" > "$WEB_STAGE/.hive-web-version"
+  WEB_ARCHIVE="$SANDBOX/hive-web-$VERSION.tar.gz"
+  tar -C "$WEB_STAGE" -czf "$WEB_ARCHIVE" .
+fi
+
+WEB_ARCHIVE="$(cd "$(dirname "$WEB_ARCHIVE")" && pwd)/$(basename "$WEB_ARCHIVE")"
+export XDG_CONFIG_HOME="$SANDBOX/config"
+export XDG_DATA_HOME="$SANDBOX/data"
+export XDG_STATE_HOME="$SANDBOX/state"
+export XDG_CACHE_HOME="$SANDBOX/cache"
+export HOME="$SANDBOX/home"
+mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" \
+  "$XDG_CACHE_HOME" "$HOME"
+
+ARCHIVE_SHA="$(ruby -rdigest -e 'puts Digest::SHA256.file(ARGV.fetch(0)).hexdigest' "$WEB_ARCHIVE")"
+GEM_HOME="$GEM_HOME" GEM_PATH="$GEM_HOME" ruby -I"$GEM_HOME/gems/hive-cli-$VERSION/lib" \
+  -rhive/web/app_bundle -e '
+    source = ARGV.fetch(0)
+    sha = ARGV.fetch(1)
+    copier = ->(_url, target) { FileUtils.cp(source, target) }
+    Hive::Web::AppBundle.new(
+      url: "https://example.invalid/hive-web.tar.gz",
+      sha256: sha,
+      downloader: copier
+    ).install!
+  ' "$WEB_ARCHIVE" "$ARCHIVE_SHA"
+
+PORT="$(ruby -rsocket -e 's = TCPServer.new("127.0.0.1", 0); puts s.addr[1]; s.close')"
+"$HIVE_BIN" web --bind 127.0.0.1 --port "$PORT" >"$SANDBOX/web.log" 2>&1 &
+WEB_PID=$!
+
+READY=0
+for _attempt in $(seq 1 60); do
+  if curl -fsS "http://127.0.0.1:$PORT/health" >"$SANDBOX/health.json" 2>/dev/null; then
+    READY=1
+    break
+  fi
+  kill -0 "$WEB_PID" 2>/dev/null || break
+  sleep 1
+done
+
+if [[ "$READY" -ne 1 ]]; then
+  cat "$SANDBOX/web.log" >&2
+  exit 1
+fi
+
+grep -qxF "$VERSION" "$XDG_DATA_HOME/hive/web/.hive-web-version"
+test -d "$XDG_DATA_HOME/hive/web-gems"
+test -d "$XDG_STATE_HOME/hive/web-storage"
+
+kill "$WEB_PID"
+wait "$WEB_PID" || true
+WEB_PID=""
+
+# Exercise the platform manager with the packaged binary, not just the
+# foreground Rails process. systemd-user resolves units from the login user's
+# real home, while launchd loads the explicit plist path and can remain under
+# the sandbox home.
+SERVICE_HOME="$HOME"
+if [[ "$(uname -s)" == "Linux" ]]; then
+  SERVICE_HOME="${HOME_BEFORE:-${RUNNER_HOME:-}}"
+  if [[ -z "$SERVICE_HOME" ]]; then
+    echo "managed web smoke: real Linux home is unavailable" >&2
+    exit 1
+  fi
+  if ! HOME="$SERVICE_HOME" systemctl --user show-environment >/dev/null 2>&1; then
+    echo "managed web smoke: systemd-user is unavailable" >&2
+    exit 1
+  fi
+fi
+mkdir -p "$SERVICE_HOME/Library/Logs"
+
+# The RubyGems wrapper needs the isolated GEM_HOME when a service manager
+# starts it outside this shell. HIVE_INVOKED_BIN makes both units persist this
+# stable wrapper, matching the normal install.sh/Homebrew wrapper contract.
+SERVICE_BIN_DIR="$SANDBOX/service-bin"
+mkdir -p "$SERVICE_BIN_DIR"
+SERVICE_HIVE_BIN="$SERVICE_BIN_DIR/hive"
+printf '%s\n' \
+  '#!/usr/bin/env bash' \
+  "export GEM_HOME=\"$GEM_HOME\"" \
+  "export GEM_PATH=\"$GEM_PATH\"" \
+  "exec \"$HIVE_BIN\" \"\$@\"" >"$SERVICE_HIVE_BIN"
+chmod +x "$SERVICE_HIVE_BIN"
+
+# `hive setup` diagnoses command presence/auth before it mutates. Supply
+# deterministic acceptance shims for tools whose real credentials do not
+# belong on release runners; git/ruby and both service managers remain real.
+PREREQ_BIN="$SANDBOX/prereq-bin"
+mkdir -p "$PREREQ_BIN"
+for tool in qmd gh claude codex tmux node npm sqlite3 cosign; do
+  printf '%s\n' '#!/usr/bin/env bash' 'echo "3.4.0"' 'exit 0' >"$PREREQ_BIN/$tool"
+  chmod +x "$PREREQ_BIN/$tool"
+done
+SETUP_PROJECT="$SANDBOX/setup-project"
+mkdir -p "$SETUP_PROJECT"
+git -C "$SETUP_PROJECT" init -q
+
+MANAGED_SERVICE_INSTALLED=1
+(
+  cd "$SETUP_PROJECT"
+  HOME="$SERVICE_HOME" \
+    PATH="$PREREQ_BIN:$PATH" \
+    HIVE_INVOKED_BIN="$SERVICE_HIVE_BIN" \
+    HIVE_WEB_BIND=127.0.0.1 \
+    HIVE_WEB_PORT="$PORT" \
+    "$HIVE_BIN" setup --no-init --json
+) >"$SANDBOX/setup.json"
+jq -e '.schema == "hive-setup" and .ok == true and
+       ([.phases[] | select(.name == "web_readiness" and .ok == true)] | length == 1)' \
+  "$SANDBOX/setup.json" >/dev/null
+
+READY=0
+for _attempt in $(seq 1 60); do
+  if HOME="$SERVICE_HOME" HIVE_INVOKED_BIN="$SERVICE_HIVE_BIN" "$HIVE_BIN" web status \
+       --bind 127.0.0.1 --port "$PORT" --json >"$SANDBOX/web-status.json" 2>/dev/null; then
+    READY=1
+    break
+  fi
+  sleep 1
+done
+if [[ "$READY" -ne 1 ]]; then
+  cat "$SANDBOX/web-status.json" >&2 2>/dev/null || true
+  exit 1
+fi
+jq -e '.schema == "hive-web-status" and .ready == true' "$SANDBOX/web-status.json" >/dev/null
+
+HOME="$SERVICE_HOME" HIVE_INVOKED_BIN="$SERVICE_HIVE_BIN" \
+  "$HIVE_BIN" daemon repair --json >"$SANDBOX/daemon-repair.json"
+jq -e '.schema == "hive-daemon-maintenance" and .ok == true' "$SANDBOX/daemon-repair.json" >/dev/null
+HOME="$SERVICE_HOME" HIVE_INVOKED_BIN="$SERVICE_HIVE_BIN" \
+  "$HIVE_BIN" daemon restart --json >"$SANDBOX/daemon-restart.json"
+jq -e '.schema == "hive-daemon-maintenance" and .ok == true' "$SANDBOX/daemon-restart.json" >/dev/null
+
+HOME="$SERVICE_HOME" HIVE_INVOKED_BIN="$SERVICE_HIVE_BIN" "$HIVE_BIN" web stop \
+  --bind 127.0.0.1 --port "$PORT" --json >"$SANDBOX/web-stop.json"
+jq -e '.schema == "hive-web-status" and .operation == "stop" and .ok == true' \
+  "$SANDBOX/web-stop.json" >/dev/null
+
+HOME="$SERVICE_HOME" "$HIVE_BIN" uninstall --purge >"$SANDBOX/uninstall.log"
+MANAGED_SERVICE_INSTALLED=0
+test ! -e "$SERVICE_HOME/.config/systemd/user/hive-web.service"
+test ! -e "$SERVICE_HOME/Library/LaunchAgents/local.hive-web.plist"
+test ! -d "$XDG_DATA_HOME/hive/web"
+
+printf 'local web foreground and managed lifecycle passed at http://127.0.0.1:%s\n' "$PORT"
diff --git a/packaging/verify-release.sh b/packaging/verify-release.sh
index a66153ea..c82f74e2 100755
--- a/packaging/verify-release.sh
+++ b/packaging/verify-release.sh
@@ -28,7 +28,7 @@
 #   0   all verifications passed
 #   1   a verification step failed (script preserves the tmp prefix)
 #   2   bad arguments
-#   3   prerequisite missing (curl, ruby, jq, git)
+#   3   prerequisite missing (curl, ruby, jq, git, cosign)
 
 set -euo pipefail
 
@@ -57,7 +57,7 @@ EXIT CODES:
   0   all verifications passed
   1   a verification step failed (script preserves the tmp prefix)
   2   bad arguments
-  3   prerequisite missing (curl, ruby, jq, git)
+  3   prerequisite missing (curl, ruby, jq, git, cosign)
 HELP
 }
 
@@ -100,7 +100,7 @@ INSTALL_SH="$REPO_ROOT/install.sh"
 
 # ─── prerequisites ───────────────────────────────────────────────────
 
-for cmd in curl ruby jq git; do
+for cmd in curl ruby jq git cosign; do
   command -v "$cmd" >/dev/null 2>&1 || {
     echo "verify-release: missing prerequisite: $cmd" >&2
     exit 3
@@ -303,6 +303,48 @@ else
   fail "install-channel sidecar missing at $XDG_DATA_HOME/hive/install-channel"
 fi
 
+# Releases that expose the first-class setup command must also publish the
+# version-matched Rails asset in the same authenticated checksum manifest.
+# Older pinned releases are feature-detected and retain their historical gate.
+if "$XDG_BIN_HOME/hive" help setup >/dev/null 2>&1; then
+  step "matching local web release bundle"
+  RELEASE_BASE="https://github.com/ivankuznetsov/hive/releases/download/$HIVE_VERSION"
+  WEB_VERSION="${HIVE_VERSION#v}"
+  WEB_ASSET="hive-web-${WEB_VERSION}.tar.gz"
+  curl -fsSL --connect-timeout 10 --max-time 60 \
+    "$RELEASE_BASE/SHA256SUMS" -o "$PREFIX/web-SHA256SUMS"
+  curl -fsSL --connect-timeout 10 --max-time 60 \
+    "$RELEASE_BASE/SHA256SUMS.sig" -o "$PREFIX/web-SHA256SUMS.sig"
+  curl -fsSL --connect-timeout 10 --max-time 60 \
+    "$RELEASE_BASE/SHA256SUMS.pem" -o "$PREFIX/web-SHA256SUMS.pem"
+  if cosign verify-blob \
+       --certificate "$PREFIX/web-SHA256SUMS.pem" \
+       --signature "$PREFIX/web-SHA256SUMS.sig" \
+       --certificate-identity \
+         "https://github.com/ivankuznetsov/hive/.github/workflows/release.yml@refs/tags/$HIVE_VERSION" \
+       --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
+       "$PREFIX/web-SHA256SUMS" >/dev/null; then
+    ok "SHA256SUMS has an authenticated Hive release signature"
+  else
+    fail "SHA256SUMS signature verification failed"
+    exit 1
+  fi
+  curl -fsSL --connect-timeout 10 --max-time 120 \
+    "$RELEASE_BASE/$WEB_ASSET" -o "$PREFIX/$WEB_ASSET"
+  WEB_EXPECTED="$(awk -v asset="$WEB_ASSET" '$2 == asset || $2 == "*" asset { print $1; exit }' "$PREFIX/web-SHA256SUMS")"
+  WEB_ACTUAL="$(ruby -rdigest -e 'puts Digest::SHA256.file(ARGV.fetch(0)).hexdigest' "$PREFIX/$WEB_ASSET")"
+  if [[ -n "$WEB_EXPECTED" && "$WEB_EXPECTED" == "$WEB_ACTUAL" ]]; then
+    ok "$WEB_ASSET matches the signed release manifest"
+  else
+    fail "$WEB_ASSET is missing from, or mismatched with, SHA256SUMS"
+  fi
+  if tar -xOf "$PREFIX/$WEB_ASSET" ./.hive-web-version 2>/dev/null | grep -qx "$WEB_VERSION"; then
+    ok "$WEB_ASSET embeds version $WEB_VERSION"
+  else
+    fail "$WEB_ASSET has a missing or mismatched .hive-web-version"
+  fi
+fi
+
 # ─── 2. doctor ───────────────────────────────────────────────────────
 
 step "hive doctor"
diff --git a/schemas/hive-daemon-maintenance.v1.json b/schemas/hive-daemon-maintenance.v1.json
new file mode 100644
index 00000000..fb36c9d9
--- /dev/null
+++ b/schemas/hive-daemon-maintenance.v1.json
@@ -0,0 +1,30 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/schemas/hive-daemon-maintenance.v1.json",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["schema", "schema_version", "action", "ok", "message", "completed_at", "refused", "active_tasks", "forced_interruptions"],
+  "properties": {
+    "schema": { "const": "hive-daemon-maintenance" },
+    "schema_version": { "const": 1 },
+    "action": { "enum": ["repair", "restart"] },
+    "ok": { "type": "boolean" },
+    "message": { "type": "string" },
+    "completed_at": { "type": "string", "format": "date-time" },
+    "refused": { "type": "boolean" },
+    "active_tasks": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": ["project", "slug", "stage"],
+        "properties": {
+          "project": { "type": "string" },
+          "slug": { "type": "string" },
+          "stage": { "type": "string" }
+        }
+      }
+    },
+    "forced_interruptions": { "type": "integer", "minimum": 0 }
+  }
+}
diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json
index 1ee77d0d..ef537930 100644
--- a/schemas/hive-daemon-status.v1.json
+++ b/schemas/hive-daemon-status.v1.json
@@ -19,10 +19,18 @@
         "uptime_sec",
         "pid_file",
         "log_file",
+        "platform",
         "service_installed",
         "service_enabled",
         "unit_path",
+        "installed_executable",
+        "installed_version",
+        "current_executable",
         "current_version",
+        "drift",
+        "drift_message",
+        "last_maintenance",
+        "ready",
         "update_nudge"
       ],
       "properties": {
@@ -49,6 +57,7 @@
           "type": "string",
           "description": "Absolute path of the daemon's JSON-line log file."
         },
+        "platform": { "type": ["string", "null"], "enum": ["linux", "macos", "unsupported", null] },
         "service_installed": {
           "type": ["boolean", "null"],
           "description": "Whether the per-user autostart unit file exists on disk (non-mutating probe). Always present; null only if the probe itself could not run."
@@ -61,10 +70,37 @@
           "type": ["string", "null"],
           "description": "Absolute path of the autostart unit file; null on unsupported platforms or if the probe could not run."
         },
+        "installed_executable": { "type": ["string", "null"] },
+        "installed_version": { "type": ["string", "null"] },
+        "current_executable": { "type": ["string", "null"] },
         "current_version": {
           "type": "string",
           "description": "The running hive version, so a caller can compare against update_nudge.latest itself."
         },
+        "drift": {
+          "enum": ["none", "path", "version", "unparseable", "unreadable", "not_applicable"]
+        },
+        "drift_message": { "type": ["string", "null"] },
+        "last_maintenance": {
+          "oneOf": [
+            { "type": "null" },
+            {
+              "type": "object",
+              "additionalProperties": false,
+              "required": ["action", "ok", "message", "completed_at", "refused", "active_tasks", "forced_interruptions"],
+              "properties": {
+                "action": { "enum": ["repair", "restart"] },
+                "ok": { "type": "boolean" },
+                "message": { "type": "string" },
+                "completed_at": { "type": "string", "format": "date-time" },
+                "refused": { "type": "boolean" },
+                "active_tasks": { "type": "array", "items": { "type": "object" } },
+                "forced_interruptions": { "type": "integer", "minimum": 0 }
+              }
+            }
+          ]
+        },
+        "ready": { "type": "boolean" },
         "update_nudge": {
           "type": ["object", "null"],
           "description": "Available-update nudge written by the daemon, or null when up to date / unknown.",
diff --git a/schemas/hive-setup.v1.json b/schemas/hive-setup.v1.json
new file mode 100644
index 00000000..6f02cde2
--- /dev/null
+++ b/schemas/hive-setup.v1.json
@@ -0,0 +1,49 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/schemas/hive-setup.v1.json",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["schema", "schema_version", "ok", "checks", "phases", "url"],
+  "properties": {
+    "schema": { "const": "hive-setup" },
+    "schema_version": { "const": 1 },
+    "ok": { "type": "boolean" },
+    "checks": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/check" }
+    },
+    "phases": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/phase" }
+    },
+    "url": { "type": ["string", "null"] }
+  },
+  "$defs": {
+    "check": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "category", "detected", "required", "status", "remediation", "bootstrappable", "message"],
+      "properties": {
+        "name": { "type": "string" },
+        "category": { "type": "string" },
+        "detected": { "type": ["string", "null"] },
+        "required": { "type": ["string", "null"] },
+        "status": { "enum": ["pass", "warning", "fail", "missing"] },
+        "remediation": { "type": "array", "items": { "type": "string" } },
+        "bootstrappable": { "type": "boolean" },
+        "message": { "type": ["string", "null"] }
+      }
+    },
+    "phase": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "ok", "message", "remediation"],
+      "properties": {
+        "name": { "type": "string" },
+        "ok": { "type": "boolean" },
+        "message": { "type": "string" },
+        "remediation": { "type": "array", "items": { "type": "string" } }
+      }
+    }
+  }
+}
diff --git a/schemas/hive-web-install.v1.json b/schemas/hive-web-install.v1.json
new file mode 100644
index 00000000..0e3ae109
--- /dev/null
+++ b/schemas/hive-web-install.v1.json
@@ -0,0 +1,19 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/schemas/hive-web-install.v1.json",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["schema", "schema_version", "ok", "outcome", "platform", "unit_path", "backup_path", "restarted", "url", "messages"],
+  "properties": {
+    "schema": { "const": "hive-web-install" },
+    "schema_version": { "const": 1 },
+    "ok": { "type": "boolean" },
+    "outcome": { "enum": ["written", "upgraded", "unchanged", "unsupported", "drifted", "failed"] },
+    "platform": { "enum": ["linux", "macos", "unsupported"] },
+    "unit_path": { "type": ["string", "null"] },
+    "backup_path": { "type": ["string", "null"] },
+    "restarted": { "type": "boolean" },
+    "url": { "type": "string" },
+    "messages": { "type": "array", "items": { "type": "string" } }
+  }
+}
diff --git a/schemas/hive-web-status.v1.json b/schemas/hive-web-status.v1.json
new file mode 100644
index 00000000..07a307aa
--- /dev/null
+++ b/schemas/hive-web-status.v1.json
@@ -0,0 +1,19 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/schemas/hive-web-status.v1.json",
+  "type": "object",
+  "additionalProperties": true,
+  "required": ["schema", "schema_version", "ok", "operation", "service_installed", "service_enabled", "running", "ready", "url", "failure"],
+  "properties": {
+    "schema": { "const": "hive-web-status" },
+    "schema_version": { "const": 1 },
+    "ok": { "type": "boolean" },
+    "operation": { "enum": ["start", "stop", "status"] },
+    "service_installed": { "type": ["boolean", "null"] },
+    "service_enabled": { "type": ["boolean", "null"] },
+    "running": { "type": "boolean" },
+    "ready": { "type": "boolean" },
+    "url": { "type": "string" },
+    "failure": { "type": ["string", "null"] }
+  }
+}
diff --git a/test/e2e/lib/repro_script_writer.rb b/test/e2e/lib/repro_script_writer.rb
index f0f3c06d..a1421dc8 100644
--- a/test/e2e/lib/repro_script_writer.rb
+++ b/test/e2e/lib/repro_script_writer.rb
@@ -1,6 +1,7 @@
 require "fileutils"
 require "rbconfig"
 require "shellwords"
+require "uri"
 require_relative "paths"
 require_relative "path_safety"
 require_relative "sandbox_env"
@@ -118,6 +119,10 @@ module Hive
           emit_spawn_background(step)
         when "stop_process"
           emit_stop_process(step)
+        when "http_assert"
+          emit_http_assert(step)
+        when "http_form"
+          emit_http_form(step)
         else
           [ "# step #{step.position} skipped: kind=#{step.kind} (stateful)" ]
         end
@@ -129,6 +134,63 @@ module Hive
         ]
       end
 
+      def emit_http_assert(step)
+        url = expand_string(step.args.fetch("url"))
+        status = Integer(step.args.fetch("status", 200))
+        contains = step.args.key?("contains") ? expand_string(step.args["contains"]) : nil
+        timeout = (step.args["timeout"] || 10).to_f
+        ruby = [
+          "require 'net/http'",
+          "require 'uri'",
+          "uri = URI(#{url.inspect})",
+          "deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + #{timeout.inspect}",
+          "loop do",
+          "  begin",
+          "    response = Net::HTTP.start(uri.host, uri.port, open_timeout: 0.5, read_timeout: 1) { |http| http.get(uri.request_uri) }",
+          "    ok = response.code.to_i == #{status.inspect}",
+          ("    ok &&= response.body.include?(#{contains.inspect})" if contains),
+          "    exit 0 if ok",
+          "  rescue SystemCallError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout",
+          "  end",
+          "  break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline",
+          "  sleep 0.2",
+          "end",
+          "abort('http_assert failed for #{url}')"
+        ].compact.join("\n")
+        [
+          "# step #{step.position} http_assert: #{url}",
+          Shellwords.join([ RbConfig.ruby, "-e", ruby ])
+        ]
+      end
+
+      def emit_http_form(step)
+        url = expand_string(step.args.fetch("url"))
+        csrf_from = expand_string(step.args.fetch("csrf_from", URI(url).merge("/").to_s))
+        form = expand(step.args.fetch("form")).transform_keys(&:to_s)
+        status = Integer(step.args.fetch("status", 302))
+        ruby = [
+          "require 'cgi'",
+          "require 'net/http'",
+          "require 'uri'",
+          "uri = URI(#{url.inspect})",
+          "csrf_uri = URI(#{csrf_from.inspect})",
+          "page = Net::HTTP.get_response(csrf_uri)",
+          "token = page.body.match(/<meta[^>]+name=[\\\"']csrf-token[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']/)",
+          "abort('no CSRF token found') unless token",
+          "request = Net::HTTP::Post.new(uri)",
+          "request['X-CSRF-Token'] = CGI.unescapeHTML(token[1])",
+          "cookies = page.get_fields('set-cookie').to_a.map { |header| header.split(';', 2).first }",
+          "request['Cookie'] = cookies.join('; ') unless cookies.empty?",
+          "request.set_form_data(#{form.inspect})",
+          "response = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) }",
+          "abort(\"expected HTTP #{status}, got \#{response.code}\") unless response.code.to_i == #{status}"
+        ].join("\n")
+        [
+          "# step #{step.position} http_form: #{url}",
+          Shellwords.join([ RbConfig.ruby, "-e", ruby ])
+        ]
+      end
+
       def emit_cli(step, env_overrides: {})
         args = expand(step.args.fetch("args")).map(&:to_s)
         env = expand(step.args["env"] || {}).merge(env_overrides)
diff --git a/test/e2e/lib/scenario_parser.rb b/test/e2e/lib/scenario_parser.rb
index 3cd973d8..4faef80f 100644
--- a/test/e2e/lib/scenario_parser.rb
+++ b/test/e2e/lib/scenario_parser.rb
@@ -19,7 +19,7 @@ module Hive
       STEP_KINDS = %w[
         cli tui_keys tui_expect tui_refute state_assert json_assert seed_state write_file
         register_project wait_subprocess editor_action log_assert ruby_block
-        start_releases_stub spawn_background stop_process
+        start_releases_stub spawn_background stop_process http_assert http_form
       ].freeze
 
       REQUIRED_KEYS = {
@@ -36,7 +36,9 @@ module Hive
         "ruby_block" => %w[block],
         "start_releases_stub" => %w[tag],
         "spawn_background" => %w[id args],
-        "stop_process" => %w[id]
+        "stop_process" => %w[id],
+        "http_assert" => %w[url],
+        "http_form" => %w[url form]
       }.freeze
 
       def self.parse(path)
diff --git a/test/e2e/lib/step_executor.rb b/test/e2e/lib/step_executor.rb
index a1bc4cd0..656d2644 100644
--- a/test/e2e/lib/step_executor.rb
+++ b/test/e2e/lib/step_executor.rb
@@ -170,6 +170,12 @@ module Hive
         return unless step.args.key?("pick")
 
         actual = pick(doc, Array(step.args["pick"]))
+        if step.args.key?("matches")
+          expected = Regexp.new(expand_string(step.args["matches"]))
+          return if actual.to_s.match?(expected)
+
+          raise StepFailure.new(step, "expected #{step.args['pick'].inspect} to match #{expected.inspect}, got #{actual.inspect}")
+        end
         expected = expand(step.args["equals"])
         return if actual == expected
 
@@ -315,6 +321,77 @@ module Hive
         proc.stop
       end
 
+      def step_http_assert(step)
+        require "net/http"
+        require "uri"
+        uri = URI(expand_string(step.args.fetch("url")))
+        expected_status = Integer(step.args.fetch("status", 200))
+        expected_body = step.args.key?("contains") ? expand_string(step.args["contains"]) : nil
+        deadline = monotonic_time + (step.args["timeout"] || 10).to_f
+        last_error = nil
+        loop do
+          begin
+            response = Net::HTTP.start(
+              uri.host,
+              uri.port,
+              open_timeout: 0.5,
+              read_timeout: 1
+            ) { |http| http.get(uri.request_uri) }
+            return if response.code.to_i == expected_status &&
+                      (expected_body.nil? || response.body.include?(expected_body))
+
+            last_error = "HTTP #{response.code}; body did not satisfy the assertion"
+          rescue SystemCallError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
+            last_error = "#{e.class}: #{e.message}"
+          end
+          break if monotonic_time >= deadline
+
+          sleep 0.2
+        end
+        raise StepFailure.new(
+          step,
+          "expected #{uri} to return #{expected_status}" \
+          "#{expected_body ? " containing #{expected_body.inspect}" : ""}; #{last_error}"
+        )
+      end
+
+      def step_http_form(step)
+        require "cgi"
+        require "net/http"
+        require "uri"
+        uri = URI(expand_string(step.args.fetch("url")))
+        csrf_uri = URI(expand_string(step.args.fetch("csrf_from", "#{uri.scheme}://#{uri.host}:#{uri.port}/")))
+        page = Net::HTTP.start(
+          csrf_uri.host,
+          csrf_uri.port,
+          open_timeout: 1,
+          read_timeout: 2
+        ) { |http| http.get(csrf_uri.request_uri) }
+        token_match = page.body.match(/<meta[^>]+name=["']csrf-token["'][^>]+content=["']([^"']+)["']/)
+        raise StepFailure.new(step, "no CSRF token found at #{csrf_uri}") unless token_match
+
+        request = Net::HTTP::Post.new(uri)
+        request["X-CSRF-Token"] = CGI.unescapeHTML(token_match[1])
+        cookies = page.get_fields("set-cookie").to_a.map { |header| header.split(";", 2).first }
+        request["Cookie"] = cookies.join("; ") unless cookies.empty?
+        request.set_form_data(expand(step.args.fetch("form")).transform_keys(&:to_s))
+        response = Net::HTTP.start(
+          uri.host,
+          uri.port,
+          open_timeout: 1,
+          read_timeout: 5
+        ) { |http| http.request(request) }
+        expected_status = Integer(step.args.fetch("status", 302))
+        return if response.code.to_i == expected_status
+
+        raise StepFailure.new(
+          step,
+          "expected POST #{uri} to return #{expected_status}, got #{response.code}"
+        )
+      rescue URI::InvalidURIError, SystemCallError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
+        raise StepFailure.new(step, "POST #{uri || step.args['url']} failed: #{e.class}: #{e.message}")
+      end
+
       def step_tui_refute(step)
         tmux = @tmux_lifecycle.start_session
         anchor = expand_string(step.args.fetch("anchor"))
diff --git a/test/e2e/scenarios/local_web_shared_state.yml b/test/e2e/scenarios/local_web_shared_state.yml
new file mode 100644
index 00000000..b21b1ba6
--- /dev/null
+++ b/test/e2e/scenarios/local_web_shared_state.yml
@@ -0,0 +1,44 @@
+name: local_web_shared_state
+description: CLI/TUI and local Rails observe one enrolled project and one task tree.
+tags: [local-web, shared-state, daemon]
+steps:
+  - kind: spawn_background
+    id: web
+    args: [web, --bind, "127.0.0.1", --port, "18473"]
+  - kind: http_assert
+    url: "http://127.0.0.1:18473/"
+    status: 200
+    contains: "{project}"
+    timeout: 30
+  - kind: http_form
+    url: "http://127.0.0.1:18473/ideas"
+    form:
+      project: "{project}"
+      text: "created from the web for the CLI surface"
+    status: 302
+  - kind: json_assert
+    args: [status, --json]
+    schema: hive-status
+    pick: [projects, 0, tasks, 0, slug]
+    matches: "^created-from-the-web-for-"
+  - kind: cli
+    args: [new, "{project}", "created from the CLI for the web surface"]
+  - kind: state_assert
+    path: "{task_dir:1-inbox}/idea.md"
+    contains: "created from the CLI for the web surface"
+  - kind: http_assert
+    url: "http://127.0.0.1:18473/"
+    status: 200
+    contains: "{slug}"
+    timeout: 30
+  - kind: spawn_background
+    id: daemon
+    args: [daemon, start, --dry-run]
+  - kind: log_assert
+    path: "{run_home}/logs/daemon.log"
+    match: '"event":"dispatched".*"slug":"{slug}"'
+    timeout: 15
+  - kind: stop_process
+    id: daemon
+  - kind: stop_process
+    id: web
diff --git a/test/eval/support/codex_judge.rb b/test/eval/support/codex_judge.rb
index 2a35a56a..58eaeba7 100644
--- a/test/eval/support/codex_judge.rb
+++ b/test/eval/support/codex_judge.rb
@@ -1,6 +1,7 @@
 require "json"
 require "open3"
 require "timeout"
+require "hive/bounded_process"
 
 module Hive
   module Eval
@@ -43,9 +44,9 @@ module Hive
       private
 
       def capture(prompt)
-        Timeout.timeout(@timeout_sec) do
-          Open3.capture3(@command, "exec", "--json", prompt)
-        end
+        Hive::BoundedProcess.capture3(
+          @command, "exec", "--json", prompt, timeout: @timeout_sec
+        )
       end
 
       def prompt(text)
diff --git a/test/eval/support/personas.rb b/test/eval/support/personas.rb
index 13417cba..24dca1f8 100644
--- a/test/eval/support/personas.rb
+++ b/test/eval/support/personas.rb
@@ -1,6 +1,7 @@
 require "json"
 require "open3"
 require "timeout"
+require "hive/bounded_process"
 
 module Hive
   module Eval
@@ -36,9 +37,9 @@ module Hive
       private
 
       def capture(prompt)
-        Timeout.timeout(@timeout_sec) do
-          Open3.capture3(@command, "exec", "--json", prompt)
-        end
+        Hive::BoundedProcess.capture3(
+          @command, "exec", "--json", prompt, timeout: @timeout_sec
+        )
       end
 
       def prompt(bot_message)
diff --git a/test/integration/local_web_setup_test.rb b/test/integration/local_web_setup_test.rb
new file mode 100644
index 00000000..ce8f7a8f
--- /dev/null
+++ b/test/integration/local_web_setup_test.rb
@@ -0,0 +1,50 @@
+require "test_helper"
+require "json"
+require "hive/commands/init"
+require "hive/commands/setup"
+
+class LocalWebSetupIntegrationTest < Minitest::Test
+  include HiveTestHelper
+
+  class Diagnostics
+    def call = []
+  end
+
+  def test_setup_enables_the_registered_project_name_and_emits_one_json_document
+    with_xdg_home do |sandbox|
+      project = File.join(sandbox, "repo")
+      FileUtils.mkdir_p(project)
+      run!("git", "-C", project, "init", "-b", "main", "--quiet")
+      run!("git", "-C", project, "config", "user.email", "test@example.com")
+      run!("git", "-C", project, "config", "user.name", "Test")
+      File.write(File.join(project, "README.md"), "test\n")
+      run!("git", "-C", project, "add", ".")
+      run!("git", "-C", project, "commit", "-m", "initial", "--quiet")
+
+      capture_io { Hive::Commands::Init.new(project, force: true, json: false).call }
+      output = StringIO.new
+      setup = Hive::Commands::Setup.new(
+        project_root: project,
+        diagnostics: Diagnostics.new,
+        bundle_installer: -> { true },
+        daemon_installer: ->(_binary) { true },
+        daemon_status: ->(_binary) { { "ready" => true } },
+        web_installer: ->(_binary) { true },
+        web_status: -> { { "ready" => true, "url" => "http://127.0.0.1:4567" } },
+        json: true,
+        output: output
+      )
+
+      result = setup.call
+      document = JSON.parse(output.string)
+      registered = Hive::Config.project_for_path(project)
+      config = YAML.safe_load_file(File.join(project, ".hive-state", "config.yml"))
+
+      assert result.fetch("ok")
+      assert_equal result, document
+      assert_equal 1, output.string.lines.length
+      assert_equal File.basename(project), registered.fetch("name")
+      assert_equal true, config.dig("daemon", "enabled")
+    end
+  end
+end
diff --git a/test/integration/web_packaged_bootstrap_test.rb b/test/integration/web_packaged_bootstrap_test.rb
new file mode 100644
index 00000000..2f9c071a
--- /dev/null
+++ b/test/integration/web_packaged_bootstrap_test.rb
@@ -0,0 +1,56 @@
+require "test_helper"
+require "digest"
+require "rubygems/package"
+require "zlib"
+require "hive/web/app_bundle"
+
+class WebPackagedBootstrapIntegrationTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_custom_authenticated_bundle_bootstraps_from_outside_the_checkout
+    with_xdg_home do |sandbox|
+      archive = File.join(sandbox, "hive-web.tgz")
+      write_bundle(archive)
+      runner_calls = []
+      installer = Hive::Web::AppBundle.new(
+        url: "https://packages.example.invalid/hive-web.tgz",
+        sha256: Digest::SHA256.file(archive).hexdigest,
+        cli_root: File.expand_path("../..", __dir__),
+        downloader: ->(_url, target) { FileUtils.cp(archive, target) },
+        runner: ->(env, argv, chdir:) { runner_calls << [ env, argv, chdir ]; true }
+      )
+
+      Dir.chdir(sandbox) { installer.install! }
+
+      assert_equal 3, runner_calls.length
+      assert runner_calls.all? { |_env, _argv, dir| dir.start_with?(Hive::Paths.data_home) }
+      assert_equal Hive::Paths.web_gems_home, runner_calls.first[0].fetch("BUNDLE_PATH")
+      assert File.executable?(File.join(Hive::Paths.web_app_home, "bin", "rails"))
+      assert_equal Hive::VERSION,
+                   File.read(File.join(Hive::Paths.web_app_home, ".hive-web-version")).strip
+    end
+  end
+
+  private
+
+  def write_bundle(path)
+    files = {
+      ".hive-web-version" => "#{Hive::VERSION}\n",
+      "Gemfile" => "source \"https://rubygems.org\"\n",
+      "Gemfile.lock" => "GEM\n  specs:\n",
+      "config/application.rb" => "# application\n",
+      "bin/rails" => "#!/bin/sh\nexit 0\n"
+    }
+    Zlib::GzipWriter.open(path) do |gzip|
+      Gem::Package::TarWriter.new(gzip) do |tar|
+        tar.mkdir("config", 0o755)
+        tar.mkdir("bin", 0o755)
+        files.each do |name, body|
+          tar.add_file_simple(name, name == "bin/rails" ? 0o755 : 0o644, body.bytesize) do |io|
+            io.write(body)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/test/unit/agent_profile_test.rb b/test/unit/agent_profile_test.rb
index 7ef38f28..f26df47a 100644
--- a/test/unit/agent_profile_test.rb
+++ b/test/unit/agent_profile_test.rb
@@ -87,7 +87,7 @@ class AgentProfileTest < Minitest::Test
   def test_check_version_raises_when_version_check_times_out
     profile = make_profile(min_version: "1.0.0")
 
-    with_replaced_singleton_method(Timeout, :timeout, ->(_seconds, &_block) { raise Timeout::Error }) do
+    with_replaced_singleton_method(Hive::BoundedProcess, :capture3, ->(*_argv, **_kwargs) { raise Timeout::Error }) do
       err = assert_raises(Hive::AgentError) { profile.check_version! }
       assert_match(/version check timed out/, err.message)
     end
diff --git a/test/unit/bounded_process_test.rb b/test/unit/bounded_process_test.rb
new file mode 100644
index 00000000..6c7b131c
--- /dev/null
+++ b/test/unit/bounded_process_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+require "timeout"
+require "hive/bounded_process"
+
+class BoundedProcessTest < Minitest::Test
+  def test_capture3_kills_a_hung_process_group
+    started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+    assert_raises(Timeout::Error) do
+      Hive::BoundedProcess.capture3(
+        RbConfig.ruby,
+        "-e",
+        "trap('TERM') {}; sleep 60",
+        timeout: 0.1
+      )
+    end
+
+    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+    assert_operator elapsed, :<, 2
+  end
+
+  def test_run_kills_a_hung_process_group
+    started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+    assert_raises(Timeout::Error) do
+      Hive::BoundedProcess.run(
+        {},
+        RbConfig.ruby,
+        "-e",
+        "trap('TERM') {}; sleep 60",
+        chdir: Dir.pwd,
+        timeout: 0.1,
+        out: File::NULL,
+        err: File::NULL
+      )
+    end
+
+    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+    assert_operator elapsed, :<, 2
+  end
+end
diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb
index dafa1c29..f0838f59 100644
--- a/test/unit/cli_test.rb
+++ b/test/unit/cli_test.rb
@@ -519,7 +519,12 @@ class HiveCliTest < Minitest::Test
     require "hive/commands/web"
     captured = []
     recorder = Class.new do
-      define_method(:initialize) { |bind:, port:| captured << { bind: bind, port: port } }
+      define_method(:initialize) do |subcommand = nil, bind:, port:, unsafe:, force:, json:|
+        captured << {
+          subcommand: subcommand, bind: bind, port: port, unsafe: unsafe,
+          force: force, json: json
+        }
+      end
       define_method(:call) { captured << :called }
     end
 
@@ -527,14 +532,19 @@ class HiveCliTest < Minitest::Test
     Hive::Commands.send(:remove_const, :Web)
     Hive::Commands.const_set(:Web, recorder)
     begin
-      capture_io { Hive::CLI.start([ "web", "--bind", "0.0.0.0", "--port", "9123" ]) }
+      capture_io { Hive::CLI.start([ "web", "--bind", "0.0.0.0", "--port", "9123", "--unsafe" ]) }
     ensure
       Hive::Commands.send(:remove_const, :Web)
       Hive::Commands.const_set(:Web, original)
     end
 
-    assert_equal({ bind: "0.0.0.0", port: 9123 }, captured.first,
-                 "the --bind/--port flags must reach the web command")
+    assert_equal(
+      {
+        subcommand: nil, bind: "0.0.0.0", port: 9123, unsafe: true,
+        force: false, json: false
+      },
+      captured.first,
+                 "the --bind/--port/--unsafe flags must reach the web command")
     assert_equal :called, captured.last, "hive web must invoke the web command's #call"
   end
 
diff --git a/test/unit/commands/daemon_test.rb b/test/unit/commands/daemon_test.rb
index f87b788e..89ddaa2f 100644
--- a/test/unit/commands/daemon_test.rb
+++ b/test/unit/commands/daemon_test.rb
@@ -403,6 +403,41 @@ class HiveCommandsDaemonTest < Minitest::Test
     assert_match(/running \(pid 2468, uptime \d+s\)/, out)
   end
 
+  def test_repair_exposes_shared_maintenance_result_as_json
+    require "hive/commands/daemon/service_installer"
+    require "hive/web/daemon_maintenance"
+    result = {
+      "action" => "repair",
+      "ok" => true,
+      "message" => "daemon service written; no active agents were interrupted",
+      "completed_at" => Time.now.utc.iso8601,
+      "refused" => false,
+      "active_tasks" => [],
+      "forced_interruptions" => 0
+    }
+    maintenance = Object.new
+    maintenance.define_singleton_method(:call) { |_action| result }
+
+    out, _err = with_replaced_singleton_method(
+      Hive::Commands::Daemon::ServiceInstaller,
+      :new,
+      ->(**_kwargs) { Object.new }
+    ) do
+      with_replaced_singleton_method(
+        Hive::Web::DaemonMaintenance,
+        :new,
+        ->(**_kwargs) { maintenance }
+      ) do
+        capture_io { daemon("repair", json: true).call }
+      end
+    end
+
+    payload = JSON.parse(out)
+    assert_equal "hive-daemon-maintenance", payload.fetch("schema")
+    assert_equal "repair", payload.fetch("action")
+    assert_equal true, payload.fetch("ok")
+  end
+
 
   def test_reload_json_success_sends_hup_and_emits_envelope
     command = daemon("reload", json: true)
diff --git a/test/unit/commands/service_installer/base_test.rb b/test/unit/commands/service_installer/base_test.rb
index 09980af6..340552b1 100644
--- a/test/unit/commands/service_installer/base_test.rb
+++ b/test/unit/commands/service_installer/base_test.rb
@@ -331,6 +331,32 @@ class ServiceInstallerBaseTest < Minitest::Test
     assert_equal "local.hive-test", installer.launchd_label
   end
 
+  def test_launchd_loaded_job_without_pid_is_not_running
+    success = Struct.new(:success?).new(true)
+    installer = TestInstaller.new(
+      host_os: "darwin",
+      launchctl_available: true,
+      capture_runner: ->(_argv) {
+        [ "{\n  \"Label\" = \"local.hive-test\";\n  \"LastExitStatus\" = 1;\n}\n", "", success ]
+      }
+    )
+
+    refute installer.running?
+  end
+
+  def test_launchd_job_with_pid_is_running
+    success = Struct.new(:success?).new(true)
+    installer = TestInstaller.new(
+      host_os: "darwin",
+      launchctl_available: true,
+      capture_runner: ->(_argv) {
+        [ "{\n  \"PID\" = 4321;\n  \"Label\" = \"local.hive-test\";\n}\n", "", success ]
+      }
+    )
+
+    assert installer.running?
+  end
+
   # ── abstract subclass hooks ────────────────────────────────────────
   # A bare subclass that overrides NOTHING must raise NotImplementedError
   # for every identity/render hook, so a half-built subclass fails loudly
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..ab57a357
--- /dev/null
+++ b/test/unit/commands/setup/exit_code_test.rb
@@ -0,0 +1,24 @@
+require "test_helper"
+require "hive/cli"
+require "hive/commands/setup"
+
+class SetupExitCodeTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_cli_exits_nonzero_when_any_setup_phase_fails
+    fake = Class.new do
+      def initialize(**) = nil
+      def call = { "ok" => false }
+    end
+    original = Hive::Commands.const_get(:Setup)
+    Hive::Commands.send(:remove_const, :Setup)
+    Hive::Commands.const_set(:Setup, fake)
+
+    _out, _err, status = with_captured_exit { Hive::CLI.start([ "setup" ]) }
+
+    assert_equal 1, status
+  ensure
+    Hive::Commands.send(:remove_const, :Setup)
+    Hive::Commands.const_set(:Setup, original)
+  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..181210e4
--- /dev/null
+++ b/test/unit/commands/setup/orchestrator_test.rb
@@ -0,0 +1,137 @@
+require "test_helper"
+require "hive/commands/setup"
+require "hive/commands/service_installer/outcome"
+
+class SetupOrchestratorTest < Minitest::Test
+  include HiveTestHelper
+
+  Check = Struct.new(:name, :status, :bootstrappable, keyword_init: true) do
+    def success? = status == "pass"
+    def to_h
+      {
+        "name" => name, "category" => "test", "detected" => nil,
+        "required" => nil, "status" => status, "remediation" => [],
+        "bootstrappable" => bootstrappable, "message" => nil
+      }
+    end
+  end
+
+  def test_runs_phases_in_order_and_shares_one_binary
+    calls = []
+    diagnostics = Object.new
+    diagnostics.define_singleton_method(:call) do
+      calls << :diagnostics
+      [ Check.new(name: "qmd", status: "missing", bootstrappable: true) ]
+    end
+    setup = Hive::Commands::Setup.new(
+      diagnostics: diagnostics,
+      qmd_bootstrap: -> { calls << :qmd; true },
+      bundle_installer: -> { calls << :bundle; true },
+      daemon_installer: ->(binary) { calls << [ :daemon, binary ]; true },
+      daemon_status: ->(binary) { calls << [ :daemon_status, binary ]; { "ready" => true } },
+      enroller: -> { calls << :enroll; true },
+      web_installer: ->(binary) { calls << [ :web, binary ]; true },
+      web_status: -> { calls << :web_status; { "ready" => true, "url" => "http://127.0.0.1:4567" } },
+      binary: "/opt/hive/bin/hive"
+    )
+
+    result = setup.call
+
+    assert result.fetch("ok")
+    assert_equal(
+      [
+        :diagnostics, :qmd, :bundle, [ :daemon, "/opt/hive/bin/hive" ],
+        [ :daemon_status, "/opt/hive/bin/hive" ], :enroll,
+        [ :web, "/opt/hive/bin/hive" ], :web_status
+      ],
+      calls
+    )
+  end
+
+  def test_mandatory_failure_prevents_mutation
+    calls = []
+    diagnostics = Object.new
+    diagnostics.define_singleton_method(:call) do
+      [ Check.new(name: "ruby", status: "fail", bootstrappable: false) ]
+    end
+    setup = Hive::Commands::Setup.new(
+      diagnostics: diagnostics,
+      qmd_bootstrap: -> { calls << :mutation },
+      bundle_installer: -> { calls << :mutation },
+      daemon_installer: ->(*) { calls << :mutation },
+      daemon_status: ->(*) { calls << :mutation },
+      enroller: -> { calls << :mutation },
+      web_installer: ->(*) { calls << :mutation },
+      web_status: -> { calls << :mutation }
+    )
+
+    result = setup.call
+
+    refute result.fetch("ok")
+    assert_empty calls
+    assert_equal "diagnostics", result.fetch("phases").last.fetch("name")
+  end
+
+  def test_no_service_prints_foreground_next_step_without_web_mutation
+    diagnostics = Object.new
+    diagnostics.define_singleton_method(:call) { [] }
+    output = StringIO.new
+    setup = Hive::Commands::Setup.new(
+      diagnostics: diagnostics, no_service: true, output: output,
+      bundle_installer: -> { true },
+      daemon_installer: ->(*) { true },
+      daemon_status: ->(*) { { "ready" => true } },
+      enroller: -> { true },
+      web_installer: ->(*) { raise "must not install web service" },
+      web_status: -> { raise "must not probe web service" }
+    )
+
+    result = setup.call
+
+    assert result.fetch("ok")
+    assert_match(/hive web/, output.string)
+  end
+
+  def test_service_manager_fallback_finishes_prerequisites_and_reports_exact_command
+    diagnostics = Object.new
+    diagnostics.define_singleton_method(:call) { [] }
+    calls = []
+    output = StringIO.new
+    setup = Hive::Commands::Setup.new(
+      diagnostics: diagnostics,
+      output: output,
+      bundle_installer: -> { calls << :bundle; true },
+      daemon_installer: ->(*) {
+        calls << :daemon
+        Hive::Commands::ServiceInstaller::Outcome.new(:autostart_unavailable)
+      },
+      daemon_status: ->(*) { raise "must not probe an unavailable manager" },
+      enroller: -> { calls << :enroll; true },
+      web_installer: ->(*) { raise "must not install a second managed service" },
+      web_status: -> { raise "must not probe an unavailable service" }
+    )
+
+    result = setup.call
+
+    refute result.fetch("ok")
+    assert_equal %i[bundle daemon enroll], calls
+    assert_includes output.string, "hive daemon start --detach && hive web"
+    fallback = result.fetch("phases").find { |phase| phase["name"] == "web_service" }
+    assert_includes fallback.fetch("remediation").join(" "), "hive daemon start --detach && hive web"
+  end
+
+  def test_failed_phase_always_has_actionable_remediation
+    diagnostics = Object.new
+    diagnostics.define_singleton_method(:call) { [] }
+    setup = Hive::Commands::Setup.new(
+      diagnostics: diagnostics,
+      bundle_installer: -> { false }
+    )
+
+    result = setup.call
+    failure = result.fetch("phases").find { |phase| !phase["ok"] }
+
+    refute_empty failure.fetch("remediation")
+    assert_match(/hive web install/, failure.fetch("remediation").join(" "))
+  end
+end
diff --git a/test/unit/commands/uninstall_test.rb b/test/unit/commands/uninstall_test.rb
index 8d1e6be5..b2083bb3 100644
--- a/test/unit/commands/uninstall_test.rb
+++ b/test/unit/commands/uninstall_test.rb
@@ -65,7 +65,7 @@ class UninstallCommandTest < Minitest::Test
       File.write(plist, "plist\n")
       out = StringIO.new
 
-      Hive::Commands::Uninstall.new(
+      result = Hive::Commands::Uninstall.new(
         purge: true,
         output: out,
         runner: ->(_argv) { false },
@@ -93,6 +93,56 @@ class UninstallCommandTest < Minitest::Test
     end
   end
 
+  def test_web_runtime_removal_preserves_storage_without_explicit_state_purge
+    with_xdg_home do
+      [ Hive::Paths.web_app_home, Hive::Paths.web_gems_home, Hive::Paths.web_storage_home ].each do |path|
+        FileUtils.mkdir_p(path)
+      end
+
+      Hive::Commands::Uninstall.new(output: StringIO.new).send(:remove_web_runtime)
+
+      refute File.exist?(Hive::Paths.web_app_home)
+      refute File.exist?(Hive::Paths.web_gems_home)
+      assert File.exist?(Hive::Paths.web_storage_home)
+    end
+  end
+
+  def test_web_storage_is_removed_only_with_explicit_state_purge
+    with_xdg_home do
+      FileUtils.mkdir_p(Hive::Paths.web_storage_home)
+
+      Hive::Commands::Uninstall.new(
+        force_purge_state: true, output: StringIO.new
+      ).send(:remove_web_runtime)
+
+      refute File.exist?(Hive::Paths.web_storage_home)
+    end
+  end
+
+  def test_web_runtime_is_preserved_when_service_deregistration_fails
+    with_xdg_home do
+      unit = File.expand_path("~/.config/systemd/user/hive-web.service")
+      FileUtils.mkdir_p(File.dirname(unit))
+      File.write(unit, "unit\n")
+      FileUtils.mkdir_p(Hive::Paths.web_app_home)
+      File.write(File.join(Hive::Paths.web_app_home, "keep"), "active service dependency\n")
+      output = StringIO.new
+
+      result = Hive::Commands::Uninstall.new(
+        purge: true,
+        output: output,
+        runner: ->(argv) { argv.include?("hive-web") ? false : true },
+        host_os: "linux"
+      ).call
+
+      assert File.exist?(unit)
+      assert File.exist?(File.join(Hive::Paths.web_app_home, "keep"))
+      assert_match(/preserving the web runtime/, output.string)
+      assert_equal 1, result
+      refute_match(/cleanup complete/, output.string)
+    end
+  end
+
   def test_remove_user_symlinks_removes_hive_and_hv_links
     with_xdg_home do
       bin = Hive::Paths.bin_home
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..dba38e71
--- /dev/null
+++ b/test/unit/commands/web/service_installer_test.rb
@@ -0,0 +1,66 @@
+require "test_helper"
+require "hive/commands/web/service_installer"
+
+class WebServiceInstallerTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_systemd_unit_uses_exact_binary_and_xdg_paths
+    with_xdg_home do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux", home: File.join(dir, "home"),
+        binary_path: "/opt/Hive Bin/hive", systemctl_available: false
+      )
+
+      body = installer.send(:render_systemd)
+
+      assert_includes body, "ExecStart=/opt/Hive\\ Bin/hive web"
+      assert_includes body, "HIVE_WEB_BIND=127.0.0.1"
+      assert_includes body, "HIVE_INVOKED_BIN=/opt/Hive Bin/hive"
+      assert_includes body, Hive::Paths.web_gems_home
+      assert_includes body, "StartLimitBurst=3"
+      assert_equal 1, body.scan(/^Environment=PATH=/).length
+      refute_includes body, "ExecStart=hive-daemon"
+    end
+  end
+
+  def test_launchd_plist_is_xml_safe
+    with_tmp_dir do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "darwin", home: dir, binary_path: "/tmp/hive&tools/hive",
+        launchctl_available: false
+      )
+
+      body = installer.send(:render_launchd)
+
+      assert_includes body, "/tmp/hive&amp;tools/hive"
+      assert_includes body, "<string>local.hive-web</string>"
+      assert_includes body, "<string>web</string>"
+      assert_includes body, '[ -x "$0" ] || exit 0'
+      assert_includes body, "<key>PATH</key>"
+      assert_includes body, "/opt/homebrew/bin"
+    end
+  end
+
+  def test_selected_bind_port_and_unsafe_mode_are_rendered_and_rediscovered
+    with_xdg_home do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: File.join(dir, "home"),
+        binary_path: "/opt/hive",
+        bind: "0.0.0.0",
+        port: 5678,
+        unsafe: true,
+        systemctl_available: false
+      )
+
+      installer.install!(autostart: false)
+      settings = installer.installed_settings
+
+      assert_equal "0.0.0.0", settings.fetch("bind")
+      assert_equal 5678, settings.fetch("port")
+      assert_equal true, settings.fetch("unsafe")
+      assert_equal "http://0.0.0.0:5678", settings.fetch("url")
+      assert_equal false, settings.fetch("configuration_drift")
+    end
+  end
+end
diff --git a/test/unit/current_main_coverage_gap_test.rb b/test/unit/current_main_coverage_gap_test.rb
index 1c17f2a6..31b7fae6 100644
--- a/test/unit/current_main_coverage_gap_test.rb
+++ b/test/unit/current_main_coverage_gap_test.rb
@@ -89,6 +89,16 @@ class CurrentMainCoverageGapTest < Minitest::Test
     Hive::Stages::Review::AcceptedFindings.new(text: text, count: count)
   end
 
+  def with_auto_commit_capture3_stub(replacement)
+    open3_stub = ->(*argv, **_kwargs) { replacement.call(*argv) }
+    bounded_stub = ->(*argv, **_kwargs) { replacement.call(*argv) }
+    with_replaced_singleton_method(Open3, :capture3, open3_stub) do
+      with_replaced_singleton_method(Hive::BoundedProcess, :capture3, bounded_stub) do
+        yield
+      end
+    end
+  end
+
   def with_fake_profile(profile = FakeProfile.new(:codex))
     with_replaced_singleton_method(Hive::AgentProfiles, :lookup, ->(*_args, **_kwargs) { profile }) do
       yield profile
@@ -439,7 +449,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
       fail_status = ReviewCommandStatus.new(false)
       ok_status = ReviewCommandStatus.new(true)
 
-      with_replaced_singleton_method(Open3, :capture3, ->(*_argv) { [ "", "", fail_status ] }) do
+      with_auto_commit_capture3_stub(->(*_argv) { [ "", "", fail_status ] }) do
         result = Hive::Stages::Review.send(:auto_commit_fix_worktree, task, cfg, ctx, accepted_findings)
 
         refute result[:success]
@@ -452,7 +462,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         [ "test/fix_test.rb\0", "", ok_status ],
         [ "", "", ok_status ]
       ]
-      with_replaced_singleton_method(Open3, :capture3, ->(*_argv) { responses.shift }) do
+      with_auto_commit_capture3_stub(->(*_argv) { responses.shift }) do
         with_replaced_singleton_method(Hive::Stages::Review, :auto_commit_git_commit, lambda { |_worktree, _policy, _message|
           { success: false, message: "git commit failed: stderr detail\nstdout detail" }
         }) do
@@ -484,7 +494,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         [ "test/fix_test.rb\0", "", ok_status ],
         [ "", "", ok_status ]
       ]
-      with_replaced_singleton_method(Open3, :capture3, ->(*argv) {
+      with_auto_commit_capture3_stub(->(*argv) {
         captured_argv << argv
         responses.shift
       }) do
@@ -520,7 +530,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         [ "test/fix_test.rb\0", "", ok_status ],
         [ "", "reset boom", fail_status ]
       ]
-      with_replaced_singleton_method(Open3, :capture3, ->(*_argv) { responses.shift }) do
+      with_auto_commit_capture3_stub(->(*_argv) { responses.shift }) do
         with_replaced_singleton_method(Hive::Stages::Review, :auto_commit_git_commit, lambda { |_worktree, _policy, _message|
           { success: false, message: "git commit failed: commit boom" }
         }) do
@@ -566,7 +576,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         [ "", "", ok_status ]
       ]
 
-      with_replaced_singleton_method(Open3, :capture3, ->(*argv) {
+      with_auto_commit_capture3_stub(->(*argv) {
         captured_argv << argv
         responses.shift
       }) do
@@ -602,7 +612,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         [ "", "", ok_status ]
       ]
 
-      with_replaced_singleton_method(Open3, :capture3, ->(*_argv) { responses.shift }) do
+      with_auto_commit_capture3_stub(->(*_argv) { responses.shift }) do
         result = Hive::Stages::Review.send(:auto_commit_fix_worktree, task, cfg, ctx, accepted_findings)
 
         refute result[:success]
@@ -636,7 +646,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
 
       with_replaced_singleton_method(Hive::Stages::Review, :git_head, ->(_path) { "head-after" }) do
         with_replaced_singleton_method(Hive::Stages::Review, :auto_commit_git_commit, ->(_worktree, _policy, _message) { { success: true } }) do
-          with_replaced_singleton_method(Open3, :capture3, ->(*argv) {
+          with_auto_commit_capture3_stub(->(*argv) {
             captured_argv << argv
             responses.shift
           }) do
@@ -674,7 +684,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         commit_calls << [ commit_worktree, policy, message ]
         { success: true }
       }) do
-        with_replaced_singleton_method(Open3, :capture3, ->(*argv) {
+        with_auto_commit_capture3_stub(->(*argv) {
           captured_argv << argv
           responses.shift
         }) do
@@ -716,7 +726,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
         commit_calls << [ commit_worktree, policy, message ]
         { success: true }
       }) do
-        with_replaced_singleton_method(Open3, :capture3, ->(*argv) {
+        with_auto_commit_capture3_stub(->(*argv) {
           captured_argv << argv
           responses.shift
         }) do
@@ -908,7 +918,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
       ok_status = ReviewCommandStatus.new(true)
       captured_argv = []
 
-      with_replaced_singleton_method(Open3, :capture3, ->(*argv) {
+      with_auto_commit_capture3_stub(->(*argv) {
         captured_argv << argv
         [ "true\n", "", ok_status ]
       }) do
@@ -938,7 +948,7 @@ class CurrentMainCoverageGapTest < Minitest::Test
 
       fail_status = ReviewCommandStatus.new(false)
 
-      with_replaced_singleton_method(Open3, :capture3, ->(*_argv) { [ "", "fatal: bad config", fail_status ] }) do
+      with_auto_commit_capture3_stub(->(*_argv) { [ "", "fatal: bad config", fail_status ] }) do
         result = Hive::Stages::Review.send(:auto_commit_fix_worktree, task, cfg, ctx, accepted_findings)
 
         refute result[:success]
diff --git a/test/unit/daemon/status_report_test.rb b/test/unit/daemon/status_report_test.rb
new file mode 100644
index 00000000..40ab411b
--- /dev/null
+++ b/test/unit/daemon/status_report_test.rb
@@ -0,0 +1,61 @@
+require "test_helper"
+require "hive/daemon/status_report"
+
+class DaemonStatusReportTest < Minitest::Test
+  include HiveTestHelper
+
+  class Installer
+    attr_reader :path
+
+    def initialize(path)
+      @path = path
+    end
+
+    def service_state
+      {
+        "platform" => "linux", "unit_path" => path,
+        "service_installed" => true, "service_enabled" => true
+      }
+    end
+  end
+
+  class Probe
+    def read_live_pid = nil
+  end
+
+  def test_reports_path_drift_from_systemd_execstart
+    with_tmp_dir do |dir|
+      unit = File.join(dir, "hive-daemon.service")
+      File.write(unit, "[Service]\nExecStart=/old/bin/hive daemon start\n")
+      report = Hive::Daemon::StatusReport.new(
+        installer: Installer.new(unit), pid_probe: Probe.new,
+        pid_file: File.join(dir, "pid"), log_file: File.join(dir, "log"),
+        current_binary: "/new/bin/hive", current_version: Hive::VERSION,
+        runner: ->(_argv, timeout:) {
+          { ok: true, stdout: "hive #{Hive::VERSION}\n", stderr: "", exit_code: 0 }
+        }
+      ).to_h
+
+      assert_equal "/old/bin/hive", report.fetch("installed_executable")
+      assert_equal "path", report.fetch("drift")
+      assert_includes report.fetch("drift_message"), "/old/bin/hive"
+      assert_includes report.fetch("drift_message"), "/new/bin/hive"
+      refute report.fetch("ready")
+    end
+  end
+
+  def test_unparseable_installed_unit_is_unhealthy
+    with_tmp_dir do |dir|
+      unit = File.join(dir, "hive-daemon.service")
+      File.write(unit, "[Service]\nExecStart=/bin/sh -c 'echo nope'\n")
+      report = Hive::Daemon::StatusReport.new(
+        installer: Installer.new(unit), pid_probe: Probe.new,
+        pid_file: File.join(dir, "pid"), log_file: File.join(dir, "log"),
+        current_binary: "/bin/hive", current_version: Hive::VERSION
+      ).to_h
+
+      assert_equal "unparseable", report.fetch("drift")
+      refute report.fetch("ready")
+    end
+  end
+end
diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb
index a4ddfc6e..d1ae8ff7 100644
--- a/test/unit/gemspec_test.rb
+++ b/test/unit/gemspec_test.rb
@@ -25,9 +25,14 @@ 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.
+  def test_gem_package_includes_gemspec_for_managed_web_path_resolution
+    spec = Gem::Specification.load(GEMSPEC_PATH)
+
+    assert_includes spec.files, "hive.gemspec"
+  end
+
+  # The Rails tree ships as a separate version-matched release bundle. The gem
+  # keeps only the small resolver/installer and its gemspec path entry.
   def test_gem_package_excludes_the_rails_web_app
     spec = Gem::Specification.load(GEMSPEC_PATH)
 
diff --git a/test/unit/release_contract_test.rb b/test/unit/release_contract_test.rb
new file mode 100644
index 00000000..4745be90
--- /dev/null
+++ b/test/unit/release_contract_test.rb
@@ -0,0 +1,12 @@
+require "test_helper"
+
+class ReleaseContractTest < Minitest::Test
+  def test_release_builds_and_signs_the_versioned_web_bundle
+    workflow = File.read(File.expand_path("../../.github/workflows/release.yml", __dir__))
+
+    assert_includes workflow, "hive-web-${version}.tar.gz"
+    assert_includes workflow, "name: hive-web-bundle"
+    assert_includes workflow, "sha256sum hive-cli-*.gem hive-web-*.tar.gz"
+    assert_includes workflow, "cosign sign-blob"
+  end
+end
diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb
index 2d10237d..baeed474 100644
--- a/test/unit/schema_files_test.rb
+++ b/test/unit/schema_files_test.rb
@@ -1474,8 +1474,10 @@ class SchemaFilesTest < Minitest::Test
     # service_* fields are always emitted (null on probe failure), so they
     # are required-but-nullable in the schema.
     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
+      schema schema_version ok running pid uptime_sec pid_file log_file platform
+      service_installed service_enabled unit_path installed_executable
+      installed_version current_executable current_version drift drift_message
+      last_maintenance ready update_nudge
     ].sort
     assert_equal producer_required, schema_required,
                  "schema/producer required-key drift in hive-daemon-status.v1.json"
diff --git a/test/unit/setup/check_result_test.rb b/test/unit/setup/check_result_test.rb
new file mode 100644
index 00000000..0eb12608
--- /dev/null
+++ b/test/unit/setup/check_result_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+require "hive/setup/check_result"
+
+class SetupCheckResultTest < Minitest::Test
+  def test_serializes_the_setup_contract
+    result = Hive::Setup::CheckResult.new(
+      name: "ruby",
+      category: "runtime",
+      detected: "3.4.7",
+      required: ">= 3.4",
+      status: "pass",
+      remediation: [],
+      bootstrappable: false
+    )
+
+    assert result.success?
+    assert_equal(
+      {
+        "name" => "ruby",
+        "category" => "runtime",
+        "detected" => "3.4.7",
+        "required" => ">= 3.4",
+        "status" => "pass",
+        "remediation" => [],
+        "bootstrappable" => false,
+        "message" => nil
+      },
+      result.to_h
+    )
+  end
+
+  def test_rejects_unknown_status
+    error = assert_raises(ArgumentError) do
+      Hive::Setup::CheckResult.new(
+        name: "ruby", category: "runtime", detected: nil, required: nil,
+        status: "maybe", remediation: [], bootstrappable: false
+      )
+    end
+
+    assert_match(/status/, error.message)
+  end
+end
diff --git a/test/unit/setup/diagnostics_test.rb b/test/unit/setup/diagnostics_test.rb
new file mode 100644
index 00000000..164a4f9d
--- /dev/null
+++ b/test/unit/setup/diagnostics_test.rb
@@ -0,0 +1,123 @@
+require "test_helper"
+require "hive/setup/diagnostics"
+
+class SetupDiagnosticsTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_old_ruby_and_missing_external_tool_fail_with_remediation
+    runner = ->(argv, timeout:) do
+      case argv
+      when [ "gh", "auth", "status" ] then { ok: true, stdout: "", stderr: "", exit_code: 0 }
+      when [ "claude", "auth", "status" ] then { ok: true, stdout: "", stderr: "", exit_code: 0 }
+      when [ "codex", "login", "status" ] then { ok: true, stdout: "", stderr: "", exit_code: 0 }
+      else { ok: true, stdout: "1.0", stderr: "", exit_code: 0 }
+      end
+    end
+    present = %w[git tmux node npm qmd sqlite3 cosign gh claude codex].to_h { |name| [ name, "/bin/#{name}" ] }
+    present.delete("sqlite3")
+
+    results = Hive::Setup::Diagnostics.new(
+      host_os: "linux", ruby_version: "3.3.9", runner: runner,
+      which: ->(name) { present[name] }, web_bundle_current: -> { true }
+    ).call
+
+    ruby = results.find { |row| row.name == "ruby" }
+    sqlite = results.find { |row| row.name == "sqlite3" }
+    refute ruby.success?
+    assert_equal ">= 3.4", ruby.required
+    assert_match(/Ruby 3.4/, ruby.remediation.join(" "))
+    refute sqlite.success?
+    assert_match(/sqlite3/, sqlite.remediation.join(" "))
+  end
+
+  def test_missing_hive_owned_dependencies_are_bootstrappable
+    present = %w[git tmux node npm sqlite3 cosign gh claude codex].to_h { |name| [ name, "/bin/#{name}" ] }
+    runner = ->(_argv, timeout:) { { ok: true, stdout: "ok", stderr: "", exit_code: 0 } }
+
+    results = Hive::Setup::Diagnostics.new(
+      host_os: "darwin", ruby_version: "3.4.1", runner: runner,
+      which: ->(name) { present[name] }, web_bundle_current: -> { false }
+    ).call
+
+    qmd = results.find { |row| row.name == "qmd" }
+    web = results.find { |row| row.name == "web_bundle" }
+    assert qmd.bootstrappable
+    assert web.bootstrappable
+    assert_equal "missing", qmd.status
+    assert_equal "missing", web.status
+  end
+
+  def test_authentication_failure_is_distinct_from_missing_command
+    present = %w[git tmux node npm qmd sqlite3 cosign gh claude codex].to_h { |name| [ name, "/bin/#{name}" ] }
+    runner = ->(argv, timeout:) do
+      if argv == [ "gh", "auth", "status" ]
+        { ok: false, stdout: "", stderr: "not logged in", exit_code: 1 }
+      else
+        { ok: true, stdout: "ok", stderr: "", exit_code: 0 }
+      end
+    end
+
+    results = Hive::Setup::Diagnostics.new(
+      host_os: "linux", ruby_version: "3.4.1", runner: runner,
+      which: ->(name) { present[name] }, web_bundle_current: -> { true }
+    ).call
+
+    gh = results.find { |row| row.name == "gh_auth" }
+    assert_equal "fail", gh.status
+    assert_match(/gh auth login/, gh.remediation.join(" "))
+    assert_equal "not logged in", gh.message
+  end
+
+  def test_unsupported_windows_fails_without_running_probes
+    calls = []
+    results = Hive::Setup::Diagnostics.new(
+      host_os: "mingw32", ruby_version: "3.4.1",
+      runner: ->(argv, timeout:) { calls << argv; raise "must not run" },
+      which: ->(_name) { "/bin/tool" }, web_bundle_current: -> { true }
+    ).call
+
+    assert_empty calls
+    platform = results.first
+    assert_equal "platform", platform.name
+    assert_equal "fail", platform.status
+    assert_match(/Linux or macOS/, platform.remediation.join(" "))
+  end
+
+  def test_managed_qmd_is_rediscovered_when_its_bin_directory_is_not_on_path
+    with_xdg_home do
+      managed = File.join(Hive::Paths.data_home, "qmd", "bin", "qmd")
+      FileUtils.mkdir_p(File.dirname(managed))
+      File.write(managed, "#!/bin/sh\n")
+      FileUtils.chmod(0o755, managed)
+      diagnostics = Hive::Setup::Diagnostics.new(
+        host_os: "linux",
+        ruby_version: "3.4.1",
+        web_bundle_current: -> { true }
+      )
+
+      with_env("PATH" => "") do
+        assert_equal managed, diagnostics.send(:which, "qmd")
+      end
+    end
+  end
+
+  def test_custom_bundle_sha_does_not_require_cosign
+    with_env(
+      "HIVE_WEB_BUNDLE_URL" => "https://packages.example.invalid/web.tgz",
+      "HIVE_WEB_BUNDLE_SHA256" => "a" * 64
+    ) do
+      diagnostics = Hive::Setup::Diagnostics.new(
+        host_os: "linux",
+        ruby_version: "3.4.1",
+        which: ->(_name) { "/bin/tool" },
+        runner: ->(_argv, timeout:) { { ok: true, stdout: "ok", stderr: "", exit_code: 0 } },
+        web_bundle_current: -> { false }
+      )
+
+      cosign = diagnostics.call.find { |result| result.name == "cosign" }
+
+      assert cosign.success?
+      assert_match(/custom SHA-256/, cosign.detected)
+    end
+  end
+end
diff --git a/test/unit/skill_check_test.rb b/test/unit/skill_check_test.rb
index f9b15b51..7d5ead39 100644
--- a/test/unit/skill_check_test.rb
+++ b/test/unit/skill_check_test.rb
@@ -481,7 +481,7 @@ class HiveSkillCheckPiTest < Minitest::Test
   end
 
   def test_global_npm_root_returns_nil_on_timeout
-    with_replaced_singleton_method(Timeout, :timeout, ->(_seconds) { raise Timeout::Error }) do
+    with_replaced_singleton_method(Hive::BoundedProcess, :capture3, ->(*_argv, **_kwargs) { raise Timeout::Error }) do
       assert_nil Hive::SkillCheck::Pi.global_npm_root
     end
   end
@@ -491,7 +491,7 @@ class HiveSkillCheckPiTest < Minitest::Test
     status.define_singleton_method(:success?) { true }
     captured_cmd = nil
 
-    with_replaced_singleton_method(Open3, :capture3, lambda { |*cmd|
+    with_replaced_singleton_method(Hive::BoundedProcess, :capture3, lambda { |*cmd, **_kwargs|
       captured_cmd = cmd
       [ "  /tmp/npm-root\nignored\n", "", status ]
     }) do
diff --git a/test/unit/stages/clean_exit_test.rb b/test/unit/stages/clean_exit_test.rb
index 06f5ec1e..5d04b73e 100644
--- a/test/unit/stages/clean_exit_test.rb
+++ b/test/unit/stages/clean_exit_test.rb
@@ -250,7 +250,7 @@ class HiveStagesCleanExitTest < Minitest::Test
     with_tmp_dir do |worktree|
       init_git(worktree)
 
-      result = with_open3_capture3_stub(
+      result = with_bounded_capture3_stub(
         ->(argv) { argv.include?("status") }
       ) do
         Hive::Stages::CleanExit.run!(
@@ -270,7 +270,7 @@ class HiveStagesCleanExitTest < Minitest::Test
       init_git(worktree)
       File.write(File.join(worktree, "lib.rb"), "x\n")
 
-      result = with_open3_capture3_stub(
+      result = with_bounded_capture3_stub(
         ->(argv) { argv.include?("add") && argv.include?("-A") }
       ) do
         Hive::Stages::CleanExit.run!(
@@ -309,20 +309,20 @@ class HiveStagesCleanExitTest < Minitest::Test
     YAML.unsafe_load(YAML.dump(Hive::Config::DEFAULTS))
   end
 
-  # Replace `Open3.capture3` with a stub that raises `Timeout::Error`
+  # Replace `BoundedProcess.capture3` with a stub that raises `Timeout::Error`
   # whenever `predicate.call(argv)` returns truthy; otherwise delegates
   # to the real implementation. Restores the original method on exit
   # even when the block raises.
-  def with_open3_capture3_stub(predicate)
-    original = Open3.method(:capture3)
-    Open3.singleton_class.send(:define_method, :capture3) do |*argv, **kwargs|
+  def with_bounded_capture3_stub(predicate)
+    original = Hive::BoundedProcess.method(:capture3)
+    Hive::BoundedProcess.singleton_class.send(:define_method, :capture3) do |*argv, **kwargs|
       raise Timeout::Error if predicate.call(argv)
 
       original.call(*argv, **kwargs)
     end
     yield
   ensure
-    Open3.singleton_class.send(:define_method, :capture3) do |*argv, **kwargs|
+    Hive::BoundedProcess.singleton_class.send(:define_method, :capture3) do |*argv, **kwargs|
       original.call(*argv, **kwargs)
     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..9b8ddb8e
--- /dev/null
+++ b/test/unit/web/app_bundle_test.rb
@@ -0,0 +1,153 @@
+require "test_helper"
+require "digest"
+require "zlib"
+require "rubygems/package"
+require "hive/web/app_bundle"
+
+class WebAppBundleTest < Minitest::Test
+  include HiveTestHelper
+
+  def web_archive(path, version:)
+    files = {
+      ".hive-web-version" => "#{version}\n",
+      "Gemfile" => "source \"https://rubygems.org\"\n",
+      "Gemfile.lock" => "GEM\n  specs:\n    psych (#{Psych::VERSION})\n",
+      "config/application.rb" => "# app\n",
+      "bin/rails" => "#!/bin/sh\nexit 0\n"
+    }
+    Zlib::GzipWriter.open(path) do |gzip|
+      Gem::Package::TarWriter.new(gzip) do |tar|
+        tar.mkdir("config", 0o755)
+        tar.mkdir("bin", 0o755)
+        files.each do |name, body|
+          mode = name == "bin/rails" ? 0o755 : 0o644
+          tar.add_file_simple(name, mode, body.bytesize) { |io| io.write(body) }
+        end
+      end
+    end
+  end
+
+  def test_custom_url_requires_sha256
+    error = assert_raises(Hive::Error) do
+      Hive::Web::AppBundle.new(url: "https://example.test/web.tgz")
+    end
+    assert_match(/SHA-256/, error.message)
+  end
+
+  def test_current_bundle_is_reused_without_download
+    with_xdg_home do
+      active = Hive::Paths.web_app_home
+      FileUtils.mkdir_p(File.join(active, "config"))
+      FileUtils.mkdir_p(File.join(active, "bin"))
+      File.write(File.join(active, ".hive-web-version"), "#{Hive::VERSION}\n")
+      File.write(File.join(active, "Gemfile"), "source \"https://rubygems.org\"\n")
+      File.write(File.join(active, "Gemfile.lock"), "GEM\n")
+      File.write(File.join(active, "config", "application.rb"), "# app\n")
+      File.write(File.join(active, "bin", "rails"), "#!/bin/sh\n")
+      FileUtils.chmod(0o755, File.join(active, "bin", "rails"))
+      downloads = 0
+      bundle = Hive::Web::AppBundle.new(
+        downloader: ->(*) { downloads += 1 },
+        runner: ->(*) { true }
+      )
+
+      assert_equal active, bundle.install!
+      assert_equal 0, downloads
+    end
+  end
+
+  def test_matching_marker_does_not_reuse_a_partial_bundle
+    with_xdg_home do
+      active = Hive::Paths.web_app_home
+      FileUtils.mkdir_p(active)
+      File.write(File.join(active, ".hive-web-version"), "#{Hive::VERSION}\n")
+
+      refute Hive::Web::AppBundle.new(
+        downloader: ->(*) { raise "download proves repair was attempted" }
+      ).current?
+      error = assert_raises(Hive::Error) do
+        Hive::Web::AppBundle.new(
+          downloader: ->(*) { raise "download proves repair was attempted" }
+        ).install!
+      end
+      assert_match(/repair was attempted/, error.message)
+    end
+  end
+
+  def test_default_release_fails_closed_without_cosign
+    with_xdg_home do |dir|
+      archive = File.join(dir, "asset.tgz")
+      web_archive(archive, version: Hive::VERSION)
+      downloader = lambda do |url, target|
+        if url.end_with?(".tar.gz")
+          FileUtils.cp(archive, target)
+        else
+          File.write(target, "#{Digest::SHA256.file(archive).hexdigest}  hive-web-#{Hive::VERSION}.tar.gz\n")
+        end
+      end
+
+      with_env("PATH" => "") do
+        error = assert_raises(Hive::Error) do
+          Hive::Web::AppBundle.new(downloader: downloader).install!
+        end
+        assert_match(/cosign is required/, error.message)
+      end
+    end
+  end
+
+  def test_custom_bundle_url_and_sha_can_be_supplied_by_environment
+    with_env(
+      "HIVE_WEB_BUNDLE_URL" => "https://packages.example.invalid/hive-web.tgz",
+      "HIVE_WEB_BUNDLE_SHA256" => "a" * 64
+    ) do
+      bundle = Hive::Web::AppBundle.new
+
+      assert_equal "https://packages.example.invalid/hive-web.tgz", bundle.url
+    end
+  end
+
+  def test_verified_bundle_is_prepared_and_activated
+    with_xdg_home do |dir|
+      archive = File.join(dir, "asset.tgz")
+      web_archive(archive, version: Hive::VERSION)
+      calls = []
+      bundle = Hive::Web::AppBundle.new(
+        url: "https://example.test/web.tgz",
+        sha256: Digest::SHA256.file(archive).hexdigest,
+        downloader: ->(_url, target) { FileUtils.cp(archive, target) },
+        runner: ->(env, argv, chdir:) { calls << [ env, argv, chdir ]; true }
+      )
+
+      active = bundle.install!
+
+      assert_equal Hive::VERSION, File.read(File.join(active, ".hive-web-version")).strip
+      psych_link = Dir[File.join(Hive::Paths.web_gems_home, "ruby", Gem.ruby_api_version,
+                                 "specifications", "psych-*.gemspec")].first
+      assert File.symlink?(psych_link), "matching default psych should be linked into BUNDLE_PATH"
+      assert_equal 3, calls.length
+      assert_equal Hive::Paths.web_gems_home, calls.first.first.fetch("BUNDLE_PATH")
+      assert_equal "development:test", calls.first.first.fetch("BUNDLE_WITHOUT")
+      assert_equal "1", calls.first.first.fetch("SECRET_KEY_BASE_DUMMY")
+      assert_equal Hive::Paths.web_storage_home, calls.last.first.fetch("HIVE_WEB_STORAGE_DIR")
+    end
+  end
+
+  def test_checksum_failure_preserves_previous_bundle
+    with_xdg_home do |dir|
+      active = Hive::Paths.web_app_home
+      FileUtils.mkdir_p(active)
+      File.write(File.join(active, ".hive-web-version"), "old\n")
+      archive = File.join(dir, "asset.tgz")
+      web_archive(archive, version: Hive::VERSION)
+      bundle = Hive::Web::AppBundle.new(
+        url: "https://example.test/web.tgz",
+        sha256: "0" * 64,
+        downloader: ->(_url, target) { FileUtils.cp(archive, target) },
+        runner: ->(*) { true }
+      )
+
+      assert_raises(Hive::Error) { bundle.install! }
+      assert_equal "old", File.read(File.join(active, ".hive-web-version")).strip
+    end
+  end
+end
diff --git a/test/unit/web/archive_validator_test.rb b/test/unit/web/archive_validator_test.rb
new file mode 100644
index 00000000..8fc2648a
--- /dev/null
+++ b/test/unit/web/archive_validator_test.rb
@@ -0,0 +1,64 @@
+require "test_helper"
+require "zlib"
+require "rubygems/package"
+require "hive/web/archive_validator"
+
+class WebArchiveValidatorTest < Minitest::Test
+  include HiveTestHelper
+
+  def build_archive(path)
+    Zlib::GzipWriter.open(path) do |gzip|
+      Gem::Package::TarWriter.new(gzip) { |tar| yield tar }
+    end
+  end
+
+  def test_extracts_regular_files_inside_destination
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "web.tgz")
+      build_archive(archive) do |tar|
+        tar.mkdir(".", 0o755)
+        tar.mkdir("config", 0o755)
+        body = "Rails.application\n"
+        tar.add_file_simple("config/application.rb", 0o644, body.bytesize) { |io| io.write(body) }
+      end
+      destination = File.join(dir, "out")
+
+      Hive::Web::ArchiveValidator.new(archive).extract_to(destination)
+
+      assert_equal "Rails.application\n", File.read(File.join(destination, "config/application.rb"))
+    end
+  end
+
+  def test_rejects_parent_traversal
+    with_tmp_dir do |dir|
+      archive = File.join(dir, "bad.tgz")
+      build_archive(archive) do |tar|
+        tar.add_file_simple("../escaped", 0o644, 1) { |io| io.write("x") }
+      end
+
+      error = assert_raises(Hive::Error) do
+        Hive::Web::ArchiveValidator.new(archive).extract_to(File.join(dir, "out"))
+      end
+      assert_match(/unsafe archive path/, error.message)
+      refute File.exist?(File.join(dir, "escaped"))
+    end
+  end
+
+  def test_rejects_links_and_setuid_modes
+    with_tmp_dir do |dir|
+      link_archive = File.join(dir, "link.tgz")
+      build_archive(link_archive) { |tar| tar.add_symlink("escape", "../outside", 0o777) }
+      assert_raises(Hive::Error) do
+        Hive::Web::ArchiveValidator.new(link_archive).extract_to(File.join(dir, "links"))
+      end
+
+      mode_archive = File.join(dir, "mode.tgz")
+      build_archive(mode_archive) do |tar|
+        tar.add_file_simple("danger", 0o4755, 1) { |io| io.write("x") }
+      end
+      assert_raises(Hive::Error) do
+        Hive::Web::ArchiveValidator.new(mode_archive).extract_to(File.join(dir, "modes"))
+      end
+    end
+  end
+end
diff --git a/test/unit/web/daemon_maintenance_test.rb b/test/unit/web/daemon_maintenance_test.rb
new file mode 100644
index 00000000..579afb1d
--- /dev/null
+++ b/test/unit/web/daemon_maintenance_test.rb
@@ -0,0 +1,53 @@
+require "test_helper"
+require "hive/web/daemon_maintenance"
+
+class WebDaemonMaintenanceTest < Minitest::Test
+  include HiveTestHelper
+
+  class Installer
+    attr_reader :calls
+
+    def initialize
+      @calls = []
+    end
+
+    def install!(autostart:, force:)
+      @calls << [ :install, autostart, force ]
+      Hive::Commands::ServiceInstaller::Outcome.new(:written)
+    end
+
+    def stop! = @calls << [ :stop ]
+    def start! = @calls << [ :start ]
+  end
+
+  def test_allows_only_fixed_repair_and_restart_operations
+    with_xdg_home do
+      installer = Installer.new
+      maintenance = Hive::Web::DaemonMaintenance.new(installer: installer, active_tasks: -> { [] })
+
+      assert maintenance.call("repair").fetch("ok")
+      assert_equal [ :install, true, true ], installer.calls.first
+      assert_raises(Hive::InvalidTaskPath) { maintenance.call("rm -rf") }
+    end
+  end
+
+  def test_refuses_restart_and_repair_while_agents_are_active
+    with_xdg_home do
+      installer = Installer.new
+      task = { "project" => "demo", "slug" => "ship-it", "stage" => "4-execute" }
+      maintenance = Hive::Web::DaemonMaintenance.new(
+        installer: installer,
+        active_tasks: -> { [ task ] }
+      )
+
+      %w[restart repair].each do |action|
+        result = maintenance.call(action)
+        refute result.fetch("ok")
+        assert result.fetch("refused")
+        assert_equal [ task ], result.fetch("active_tasks")
+        assert_equal 0, result.fetch("forced_interruptions")
+      end
+      assert_empty installer.calls
+    end
+  end
+end
diff --git a/test/unit/web/environment_test.rb b/test/unit/web/environment_test.rb
new file mode 100644
index 00000000..d225f80a
--- /dev/null
+++ b/test/unit/web/environment_test.rb
@@ -0,0 +1,89 @@
+require "test_helper"
+require "hive/web/environment"
+
+class WebEnvironmentTest < Minitest::Test
+  include HiveTestHelper
+
+  def base_config
+    {
+      "bind" => "127.0.0.1",
+      "port" => 4567,
+      "origin" => "http://127.0.0.1:4567",
+      "github" => { "owner" => nil },
+      "session_secret_file" => File.join(Dir.tmpdir, "secret")
+    }
+  end
+
+  def test_canonical_environment_wins_over_hivebox_aliases
+    with_env(
+      "HIVE_WEB_BIND" => "127.0.0.2",
+      "HIVEBOX_BIND" => "0.0.0.0",
+      "HIVE_WEB_PORT" => "5678",
+      "HIVEBOX_PORT" => "6789"
+    ) do
+      environment = Hive::Web::Environment.new(config: base_config)
+
+      assert_equal "127.0.0.2", environment.bind
+      assert_equal 5678, environment.port
+    end
+  end
+
+  def test_non_loopback_requires_auth_flow_or_unsafe
+    error = assert_raises(Hive::Error) do
+      Hive::Web::Environment.new(config: base_config, bind: "0.0.0.0").validate_security!
+    end
+    assert_match(/refusing non-loopback bind/, error.message)
+
+    claimable = base_config.merge("github" => { "owner" => nil, "client_id" => "device-flow-client" })
+    assert Hive::Web::Environment.new(config: claimable, bind: "0.0.0.0").validate_security!
+    assert Hive::Web::Environment.new(config: base_config, bind: "0.0.0.0", unsafe: true).validate_security!
+  end
+
+  def test_rails_env_contains_real_xdg_roots_and_compatibility_aliases
+    with_xdg_home do
+      environment = Hive::Web::Environment.new(config: base_config)
+      env = environment.to_h(app_dir: "/tmp/web", cli_root: "/tmp/hive")
+
+      assert_equal Hive::Paths.config_home, env.fetch("HIVE_CONFIG_HOME")
+      refute env.key?("HIVE_WEB_STORAGE_DIR")
+      refute env.key?("HIVEBOX_STORAGE_DIR")
+      assert_equal "true", env.fetch("HIVE_WEB_LOCAL_MODE")
+      assert_equal "/tmp/hive", env.fetch("HIVE_CLI_ROOT")
+      refute env.key?("BUNDLE_PATH")
+      refute env.key?("BUNDLE_WITHOUT")
+    end
+  end
+
+  def test_managed_mode_uses_managed_bundle_path_without_changing_source_mode
+    with_xdg_home do
+      environment = Hive::Web::Environment.new(config: base_config)
+      managed = environment.to_h(app_dir: "/tmp/web", cli_root: "/tmp/hive", managed: true)
+
+      assert_equal Hive::Paths.web_gems_home, managed.fetch("BUNDLE_PATH")
+      assert_equal "development:test", managed.fetch("BUNDLE_WITHOUT")
+      assert_equal Hive::Paths.web_storage_home, managed.fetch("HIVE_WEB_STORAGE_DIR")
+      assert_equal managed.fetch("HIVE_WEB_STORAGE_DIR"), managed.fetch("HIVEBOX_STORAGE_DIR")
+    end
+  end
+
+  def test_canonical_storage_wins_but_legacy_hivebox_storage_is_preserved
+    with_xdg_home do
+      with_env("HIVE_WEB_STORAGE_DIR" => nil, "HIVEBOX_STORAGE_DIR" => "/data/legacy") do
+        env = Hive::Web::Environment.new(config: base_config)
+                                    .to_h(app_dir: "/app", cli_root: "/hive")
+        assert_equal "/data/legacy", env.fetch("HIVE_WEB_STORAGE_DIR")
+        assert_equal "/data/legacy", env.fetch("HIVEBOX_STORAGE_DIR")
+      end
+
+      with_env(
+        "HIVE_WEB_STORAGE_DIR" => "/state/canonical",
+        "HIVEBOX_STORAGE_DIR" => "/data/legacy"
+      ) do
+        env = Hive::Web::Environment.new(config: base_config)
+                                    .to_h(app_dir: "/app", cli_root: "/hive")
+        assert_equal "/state/canonical", env.fetch("HIVE_WEB_STORAGE_DIR")
+        assert_equal "/state/canonical", env.fetch("HIVEBOX_STORAGE_DIR")
+      end
+    end
+  end
+end
diff --git a/test/unit/web/host_authorization_test.rb b/test/unit/web/host_authorization_test.rb
new file mode 100644
index 00000000..1ec7ad2f
--- /dev/null
+++ b/test/unit/web/host_authorization_test.rb
@@ -0,0 +1,28 @@
+require "test_helper"
+require "hive/web/host_authorization"
+
+class WebHostAuthorizationTest < Minitest::Test
+  def test_allows_complete_loopback_range_and_configured_hosts
+    hosts = Hive::Web::HostAuthorization.allowed_hosts(
+      bind: "0.0.0.0",
+      origin: "https://hive.internal.example:4567"
+    )
+
+    assert_includes hosts, "localhost"
+    assert hosts.any? { |host| host.is_a?(IPAddr) && host.include?("127.0.0.2") }
+    assert hosts.any? { |host| host.is_a?(IPAddr) && host.include?("::1") }
+    assert hosts.any? { |host| host.is_a?(IPAddr) && host.include?("192.0.2.10") }
+    assert_includes hosts, "hive.internal.example"
+    refute_includes hosts, "attacker.example"
+  end
+
+  def test_ignores_an_invalid_origin_without_disabling_authorization
+    hosts = Hive::Web::HostAuthorization.allowed_hosts(
+      bind: "127.0.0.1",
+      origin: "not a valid URI"
+    )
+
+    assert_includes hosts, "127.0.0.1"
+    refute_includes hosts, nil
+  end
+end
diff --git a/test/unit/web/loopback_test.rb b/test/unit/web/loopback_test.rb
new file mode 100644
index 00000000..84ab6ba6
--- /dev/null
+++ b/test/unit/web/loopback_test.rb
@@ -0,0 +1,13 @@
+require "test_helper"
+require "hive/web/loopback"
+
+class WebLoopbackTest < Minitest::Test
+  def test_accepts_only_loopback_addresses
+    assert Hive::Web::Loopback.address?("127.0.0.1")
+    assert Hive::Web::Loopback.address?("::1")
+    assert Hive::Web::Loopback.address?("localhost")
+    refute Hive::Web::Loopback.address?("0.0.0.0")
+    refute Hive::Web::Loopback.address?("192.168.1.10")
+    refute Hive::Web::Loopback.address?("example.test")
+  end
+end
diff --git a/test/unit/web/service_status_test.rb b/test/unit/web/service_status_test.rb
new file mode 100644
index 00000000..58f5267e
--- /dev/null
+++ b/test/unit/web/service_status_test.rb
@@ -0,0 +1,72 @@
+require "test_helper"
+require "hive/web/service_status"
+
+class WebServiceStatusTest < Minitest::Test
+  class Installer
+    def service_state
+      {
+        "platform" => "linux", "unit_path" => "/tmp/hive-web.service",
+        "service_installed" => true, "service_enabled" => true
+      }
+    end
+
+    def running? = true
+    def service_manager_available? = true
+
+    def installed_settings
+      {
+        "readable" => true,
+        "bind" => "127.0.0.1",
+        "port" => 4567,
+        "unsafe" => false,
+        "url" => "http://127.0.0.1:4567",
+        "configuration_drift" => false,
+        "message" => nil
+      }
+    end
+  end
+
+  def test_running_is_not_ready_without_health
+    status = Hive::Web::ServiceStatus.new(
+      installer: Installer.new,
+      http_get: ->(_uri) { false },
+      url: "http://127.0.0.1:4567"
+    ).to_h
+
+    assert status.fetch("running")
+    refute status.fetch("ready")
+    assert_equal "health_failed", status.fetch("failure")
+  end
+
+  def test_ready_requires_service_and_health
+    status = Hive::Web::ServiceStatus.new(
+      installer: Installer.new,
+      http_get: ->(_uri) { true },
+      url: "http://127.0.0.1:4567"
+    ).to_h
+
+    assert status.fetch("ready")
+    assert_nil status.fetch("failure")
+  end
+
+  def test_installed_definition_is_probed_and_configuration_drift_is_actionable
+    installer = Installer.new
+    installer.define_singleton_method(:installed_settings) do
+      super().merge(
+        "port" => 5678,
+        "url" => "http://127.0.0.1:5678",
+        "configuration_drift" => true
+      )
+    end
+    status = Hive::Web::ServiceStatus.new(
+      installer: installer,
+      http_get: ->(uri) { uri.port == 5678 },
+      url: "http://127.0.0.1:4567"
+    ).to_h
+
+    assert_equal "http://127.0.0.1:5678", status.fetch("url")
+    assert_equal "http://127.0.0.1:4567", status.fetch("desired_url")
+    refute status.fetch("ready")
+    assert_equal "configuration_drift", status.fetch("failure")
+  end
+end
diff --git a/test/unit/web/web_command_test.rb b/test/unit/web/web_command_test.rb
index 11de4ab0..8fd407c9 100644
--- a/test/unit/web/web_command_test.rb
+++ b/test/unit/web/web_command_test.rb
@@ -4,20 +4,19 @@ require "hive/commands/web"
 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.
-  def test_missing_rails_app_exits_with_guidance
+  # Outside Docker/source, the command bootstraps the managed release bundle.
+  # A failed acquisition remains a typed CLI error rather than a raw backtrace.
+  def test_missing_rails_app_bundle_failure_is_typed
     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.
-        command.define_singleton_method(:rails_app_dir) { nil }
-        err = assert_raises(SystemExit) do
+        command.define_singleton_method(:resolve_rails_app_dir) do
+          raise Hive::Error, "hive web: matching bundle could not be installed"
+        end
+        err = assert_raises(Hive::Error) do
           capture_io { command.call }
         end
-        assert_equal 1, err.status, "a missing web app must exit 1"
+        assert_match(/matching bundle/, err.message)
       end
     end
   end
@@ -34,18 +33,26 @@ class WebCommandTest < Minitest::Test
     end
   end
 
-  def test_public_bind_without_https_origin_warns
+  def test_public_bind_allows_fresh_claim_flow_but_refuses_missing_auth
     with_tmp_global_config do
-      command = Hive::Commands::Web.new
-      _out, err = capture_io do
-        command.send(:warn_on_public_bind, "0.0.0.0", { "origin" => "http://example.test" })
+      claimable = Hive::Commands::Web.new(bind: "0.0.0.0")
+      assert claimable.send(
+        :web_environment,
+        Hive::Config.load_global_web
+      ).validate_security!
+
+      command = Hive::Commands::Web.new(bind: "0.0.0.0")
+      no_auth = Hive::Config.load_global_web.merge("github" => { "owner" => nil, "client_id" => nil })
+      error = assert_raises(Hive::Error) do
+        command.send(:web_environment, no_auth).validate_security!
       end
-      assert_match(/WARNING binding 0.0.0.0/, err, "plain-http public bind must warn about Host validation")
+      assert_match(/refusing non-loopback bind/, error.message)
 
+      unsafe = Hive::Commands::Web.new(bind: "0.0.0.0", unsafe: true)
       _out, err = capture_io do
-        command.send(:warn_on_public_bind, "0.0.0.0", { "origin" => "https://example.test" })
+        unsafe.send(:web_environment, no_auth).validate_security!
       end
-      assert_empty err, "an https origin implies a fronting proxy — no warning"
+      assert_match(/UNSAFE/, err)
     end
   end
   # Drive the full "app found" path with a stub Rails app: db:prepare
@@ -86,7 +93,7 @@ class WebCommandTest < Minitest::Test
           capture_io { Hive::Commands::Web.new.call }
         end
         assert_match(/db:prepare failed/, error.message)
-        assert_match(/writable/, error.message, "the message must point at the /data mount")
+        assert_match(/writable/, error.message, "the message must identify the source storage path")
       end
     end
   end
@@ -102,8 +109,10 @@ class WebCommandTest < Minitest::Test
         caught = nil
         begin
           capture_io { Hive::Commands::Web.new.call }
-        rescue ExecCaught => e
-          caught = e
+        rescue Hive::Error => e
+          raise unless e.cause.is_a?(ExecCaught)
+
+          caught = e.cause
         ensure
           Kernel.define_singleton_method(:exec, original)
         end
@@ -111,8 +120,57 @@ class WebCommandTest < Minitest::Test
         refute_nil caught, "the command must end in Kernel.exec of the rails server"
         assert_equal %w[bin/rails server -b], caught.argv[0..2]
         assert caught.env.key?("SECRET_KEY_BASE"), "the persisted session secret must reach Rails"
-        assert caught.env.key?("HIVEBOX_STORAGE_DIR")
+        refute caught.env.key?("HIVEBOX_STORAGE_DIR"),
+               "source execution without an override must retain Rails' in-app storage default"
+        assert_equal File.expand_path("../../../bin/hive", __dir__),
+                     caught.env.fetch("HIVE_INVOKED_BIN")
+        refute caught.env.key?("BUNDLE_PATH"), "source execution must retain its own bundle"
       end
     end
   end
+
+  def test_successful_stop_json_reports_operation_success
+    command = Hive::Commands::Web.new("stop", json: true)
+    installer = Object.new
+    installer.define_singleton_method(:stop!) { true }
+    status = {
+      "platform" => "linux",
+      "unit_path" => "/tmp/hive-web.service",
+      "service_installed" => true,
+      "service_enabled" => true,
+      "running" => false,
+      "ready" => false,
+      "url" => "http://127.0.0.1:4567",
+      "failure" => "process_not_running"
+    }
+    command.define_singleton_method(:service_installer) { installer }
+    command.define_singleton_method(:service_status) do |_installer|
+      value = status
+      Object.new.tap { |object| object.define_singleton_method(:to_h) { value } }
+    end
+
+    out, _err = capture_io { command.call }
+    payload = JSON.parse(out)
+
+    assert_equal true, payload.fetch("ok")
+    assert_equal "stop", payload.fetch("operation")
+    assert_equal false, payload.fetch("ready")
+  end
+
+  def test_lifecycle_failure_json_has_the_status_schema_shape
+    command = Hive::Commands::Web.new("start", json: true)
+    installer = Object.new
+    installer.define_singleton_method(:service_state) { { "service_installed" => false } }
+    command.define_singleton_method(:service_installer) { installer }
+
+    out, _err, status = with_captured_exit { command.call }
+    payload = JSON.parse(out)
+
+    refute_equal 0, status
+    assert_equal "hive-web-status", payload.fetch("schema")
+    assert_equal false, payload.fetch("ok")
+    assert_equal "start", payload.fetch("operation")
+    assert_equal "command_failed", payload.fetch("failure")
+    assert_equal false, payload.fetch("running")
+  end
 end
diff --git a/web/Gemfile b/web/Gemfile
index 53710d90..7b552a75 100644
--- a/web/Gemfile
+++ b/web/Gemfile
@@ -70,4 +70,4 @@ 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.
-gem "hive-cli", path: ".."
+gem "hive-cli", path: ENV["HIVE_CLI_ROOT"] || ".."
diff --git a/web/Gemfile.lock b/web/Gemfile.lock
index 2a30786c..9fc4102c 100644
--- a/web/Gemfile.lock
+++ b/web/Gemfile.lock
@@ -6,6 +6,7 @@ PATH
       faraday (>= 2.14.2, < 3.0)
       faraday-multipart (~> 1.0)
       lipgloss (~> 0.2.2)
+      rexml (~> 3.4)
       sqlite3 (~> 2.0)
       telegram-bot-ruby (~> 2.7)
       thor (~> 1.3)
@@ -284,7 +285,7 @@ GEM
       actionpack (>= 7.0.0)
       activesupport (>= 7.0.0)
       rack
-    psych (5.4.0)
+    psych (5.2.2)
       date
       stringio
     public_suffix (7.0.5)
@@ -340,6 +341,7 @@ GEM
     regexp_parser (2.12.0)
     reline (0.6.3)
       io-console (~> 0.5)
+    rexml (3.4.4)
     rubocop (1.87.0)
       json (~> 2.3)
       language_server-protocol (~> 3.17.0.2)
diff --git a/web/app/assets/stylesheets/application.css b/web/app/assets/stylesheets/application.css
index d0ae69f4..946e9b61 100644
--- a/web/app/assets/stylesheets/application.css
+++ b/web/app/assets/stylesheets/application.css
@@ -663,3 +663,24 @@ pre {
   font-size: 0.85rem;
   word-break: break-word;
 }
+/* Local service health stays useful even when the daemon itself is down. */
+.daemon-card {
+  align-items: center;
+  background: var(--surface, #fff);
+  border: 1px solid var(--border, #d8dee9);
+  border-radius: 12px;
+  display: flex;
+  gap: 1rem;
+  justify-content: space-between;
+  margin-bottom: 1rem;
+  padding: 1rem;
+}
+
+.daemon-card h2,
+.daemon-card p { margin: 0 0 .35rem; }
+.daemon-paths { color: var(--muted, #667085); font-size: .85rem; }
+.daemon-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
+
+@media (max-width: 700px) {
+  .daemon-card { align-items: stretch; flex-direction: column; }
+}
diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb
index 2a1e5e28..fa53e193 100644
--- a/web/app/controllers/application_controller.rb
+++ b/web/app/controllers/application_controller.rb
@@ -50,6 +50,7 @@ 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 +66,23 @@ class ApplicationController < ActionController::Base
     redirect_to login_path, alert: "Signed out: this box's owner changed."
   end
 
+  def local_loopback_request?
+    return false unless Rails.application.config.x.hive_web_local_mode
+
+    bind = Rails.application.config.x.hive_web_bind.to_s
+    return false unless loopback_address?(bind)
+
+    # REMOTE_ADDR is the socket peer. Deliberately ignore X-Forwarded-For:
+    # a proxy header must never manufacture the local no-auth bypass.
+    loopback_address?(request.get_header("REMOTE_ADDR"))
+  end
+
+  def loopback_address?(value)
+    value.to_s.casecmp("localhost").zero? || IPAddr.new(value.to_s).loopback?
+  rescue IPAddr::InvalidAddressError
+    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..61ad5050
--- /dev/null
+++ b/web/app/controllers/daemon_controller.rb
@@ -0,0 +1,28 @@
+class DaemonController < ApplicationController
+  def maintain
+    operation = params.require(:operation)
+    require "hive/invoked_binary"
+    require "hive/commands/daemon/service_installer"
+    require "hive/web/daemon_maintenance"
+    binary = Hive::InvokedBinary.path
+    raise Hive::Error, "cannot identify the Hive executable used to launch the web service" unless binary
+
+    installer = Hive::Commands::Daemon::ServiceInstaller.new(
+      binary_path: binary
+    )
+    result = Hive::Web::DaemonMaintenance.new(installer: installer).call(operation)
+    payload = {
+      "schema" => "hive-daemon-maintenance",
+      "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-maintenance")
+    }.merge(result)
+
+    respond_to do |format|
+      format.html do
+        redirect_to root_path,
+                    notice: result["ok"] ? result["message"] : nil,
+                    alert: result["ok"] ? nil : result["message"]
+      end
+      format.json { render json: payload, status: result["ok"] ? :ok : :service_unavailable }
+    end
+  end
+end
diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb
index c44440a8..ebdec5ee 100644
--- a/web/app/controllers/status_controller.rb
+++ b/web/app/controllers/status_controller.rb
@@ -2,5 +2,27 @@ class StatusController < ApplicationController
   def index
     @payload = StatusBroadcaster.snapshot
     @projects = @payload.fetch("projects", [])
+    @daemon_report = daemon_report
+  end
+
+  private
+
+  class DaemonProbe
+    include Hive::PidFile
+
+    def pid_file
+      File.join(Hive::Paths.state_home, ".daemon.pid")
+    end
+  end
+
+  def daemon_report
+    require "hive/daemon/status_report"
+    require "hive/commands/daemon/service_installer"
+    Hive::Daemon::StatusReport.new(
+      installer: Hive::Commands::Daemon::ServiceInstaller.new,
+      pid_probe: DaemonProbe.new,
+      pid_file: File.join(Hive::Paths.state_home, ".daemon.pid"),
+      log_file: File.join(Hive::Paths.state_home, "logs", "daemon.log")
+    ).to_h
   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..cb759d28
--- /dev/null
+++ b/web/app/views/status/_daemon.html.erb
@@ -0,0 +1,38 @@
+<section class="daemon-card" aria-labelledby="daemon-heading">
+  <div>
+    <h2 id="daemon-heading">Daemon</h2>
+    <p>
+      <strong><%= daemon["ready"] ? "Ready" : (daemon["running"] ? "Running with service issue" : "Stopped") %></strong>
+      · service <%= daemon["service_enabled"] ? "enabled" : "not enabled" %>
+      · drift <code><%= daemon["drift"] %></code>
+    </p>
+    <p class="daemon-paths">
+      Installed: <code><%= daemon["installed_executable"] || "not detected" %></code>
+      (<%= daemon["installed_version"] || "unknown version" %>)<br>
+      Current: <code><%= daemon["current_executable"] %></code>
+      (<%= daemon["current_version"] %>)
+    </p>
+    <% if daemon["drift_message"].present? %>
+      <p class="daemon-diagnostic"><strong>Why:</strong> <%= daemon["drift_message"] %></p>
+    <% end %>
+    <% if daemon["last_maintenance"] %>
+      <p>Last action: <%= daemon.dig("last_maintenance", "message") %></p>
+    <% end %>
+  </div>
+  <div class="daemon-actions">
+    <% repair_recommended = daemon["drift"] != "none" ||
+                            daemon["service_installed"] != true ||
+                            daemon["service_enabled"] != true %>
+    <% if repair_recommended %>
+      <p>Recommended: repair the service definition with the current Hive executable.</p>
+      <%= button_to "Repair service", daemon_maintenance_path("repair"),
+            method: :post, class: "btn btn-sm",
+            form: { data: { turbo_confirm: "Repair the daemon service? This refuses while agents are active." } } %>
+    <% else %>
+      <p>Recommended: restart the healthy service only if its process needs refreshing.</p>
+      <%= button_to "Restart", daemon_maintenance_path("restart"),
+            method: :post, class: "btn btn-ghost btn-sm",
+            form: { data: { turbo_confirm: "Restart the Hive daemon? This refuses while agents are active." } } %>
+    <% end %>
+  </div>
+</section>
diff --git a/web/app/views/status/index.html.erb b/web/app/views/status/index.html.erb
index 25bcdf79..cc48afd0 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_report %>
+
 <%# 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/database.yml b/web/config/database.yml
index d1c0e8fd..256fb134 100644
--- a/web/config/database.yml
+++ b/web/config/database.yml
@@ -26,21 +26,22 @@ test:
 #
 # Similarly, if you deploy your application as a Docker container, you must
 # ensure the database is located in a persisted volume.
-# Production sqlite files live under HIVEBOX_STORAGE_DIR (hive's state
-# home — the /data mount in the container) so image upgrades keep them.
+# Production sqlite files prefer the canonical local-web storage variable and
+# retain HIVEBOX_STORAGE_DIR as the Docker/source compatibility alias.
+<% hive_web_storage = ENV["HIVE_WEB_STORAGE_DIR"] || ENV["HIVEBOX_STORAGE_DIR"] || "storage" %>
 production:
   primary:
     <<: *default
-    database: <%= ENV.fetch("HIVEBOX_STORAGE_DIR", "storage") %>/production.sqlite3
+    database: <%= hive_web_storage %>/production.sqlite3
   cache:
     <<: *default
-    database: <%= ENV.fetch("HIVEBOX_STORAGE_DIR", "storage") %>/production_cache.sqlite3
+    database: <%= hive_web_storage %>/production_cache.sqlite3
     migrations_paths: db/cache_migrate
   queue:
     <<: *default
-    database: <%= ENV.fetch("HIVEBOX_STORAGE_DIR", "storage") %>/production_queue.sqlite3
+    database: <%= hive_web_storage %>/production_queue.sqlite3
     migrations_paths: db/queue_migrate
   cable:
     <<: *default
-    database: <%= ENV.fetch("HIVEBOX_STORAGE_DIR", "storage") %>/production_cable.sqlite3
+    database: <%= hive_web_storage %>/production_cable.sqlite3
     migrations_paths: db/cable_migrate
diff --git a/web/config/environments/production.rb b/web/config/environments/production.rb
index b991b661..5cb41acd 100644
--- a/web/config/environments/production.rb
+++ b/web/config/environments/production.rb
@@ -99,5 +99,14 @@ Rails.application.configure do
   # without same-origin, an unset origin silently dropped every live
   # update on any non-localhost URL — a trap on the install path.
   config.action_cable.allow_same_origin_as_host = true
-  config.action_cable.allowed_request_origins = [ ENV["HIVEBOX_ORIGIN"] ].compact
+  config.action_cable.allowed_request_origins =
+    [ ENV["HIVE_WEB_ORIGIN"] || ENV["HIVEBOX_ORIGIN"] ].compact
+
+  # Host authorization remains active in every security mode. IPAddr matches
+  # the complete loopback ranges (including 127.0.0.2), while non-loopback
+  # binds add only their explicitly configured host.
+  require "hive/web/host_authorization"
+  bind = ENV["HIVE_WEB_BIND"] || ENV["HIVEBOX_BIND"] || "127.0.0.1"
+  origin = ENV["HIVE_WEB_ORIGIN"] || ENV["HIVEBOX_ORIGIN"]
+  config.hosts = Hive::Web::HostAuthorization.allowed_hosts(bind: bind, origin: origin)
 end
diff --git a/web/config/initializers/hive.rb b/web/config/initializers/hive.rb
index f2792b55..7aa6f994 100644
--- a/web/config/initializers/hive.rb
+++ b/web/config/initializers/hive.rb
@@ -10,3 +10,4 @@ require "hive/web/telegram_validator"
 require "hive/web/telegram_tester"
 require "hive/commands/init"
 require "hive/commands/approve"
+require "hive/pid_file"
diff --git a/web/config/initializers/hive_web_environment.rb b/web/config/initializers/hive_web_environment.rb
new file mode 100644
index 00000000..07f85c15
--- /dev/null
+++ b/web/config/initializers/hive_web_environment.rb
@@ -0,0 +1,6 @@
+require "ipaddr"
+
+Rails.application.config.x.hive_web_local_mode =
+  ENV.fetch("HIVE_WEB_LOCAL_MODE", "false") == "true"
+Rails.application.config.x.hive_web_bind =
+  ENV["HIVE_WEB_BIND"] || ENV["HIVEBOX_BIND"] || "127.0.0.1"
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 659576df..d1bdb4de 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -16,6 +16,9 @@ Rails.application.routes.draw do
   post "logout" => "sessions#destroy", as: :logout
 
   root "status#index"
+  post "daemon/:operation" => "daemon#maintain",
+       as: :daemon_maintenance,
+       constraints: { operation: /repair|restart/ }
 
   post "ideas" => "ideas#create", as: :ideas
 
diff --git a/web/test/controllers/daemon_controller_test.rb b/web/test/controllers/daemon_controller_test.rb
new file mode 100644
index 00000000..0b6c22eb
--- /dev/null
+++ b/web/test/controllers/daemon_controller_test.rb
@@ -0,0 +1,24 @@
+require "test_helper"
+
+class DaemonControllerTest < ActionDispatch::IntegrationTest
+  test "maintenance routes are POST-only and closed to fixed actions" do
+    sign_in!
+
+    get "/daemon/restart"
+    assert_response :not_found
+
+    post "/daemon/arbitrary"
+    assert_response :not_found
+  end
+
+  test "status page exposes bounded daemon actions" do
+    sign_in!
+
+    get "/"
+
+    assert_response :success
+    assert_select "section.daemon-card"
+    assert_select "form[action='/daemon/repair']"
+    assert_select "form[action='/daemon/restart']", count: 0
+  end
+end
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..77bd2274
--- /dev/null
+++ b/web/test/integration/local_loopback_auth_test.rb
@@ -0,0 +1,37 @@
+require "test_helper"
+
+class LocalLoopbackAuthTest < ActionDispatch::IntegrationTest
+  setup do
+    @old_mode = Rails.application.config.x.hive_web_local_mode
+    @old_bind = Rails.application.config.x.hive_web_bind
+    Rails.application.config.x.hive_web_local_mode = true
+    Rails.application.config.x.hive_web_bind = "127.0.0.1"
+  end
+
+  teardown do
+    Rails.application.config.x.hive_web_local_mode = @old_mode
+    Rails.application.config.x.hive_web_bind = @old_bind
+  end
+
+  test "genuine loopback request bypasses login in local mode" do
+    get "/", env: { "REMOTE_ADDR" => "127.0.0.1" }
+
+    assert_response :success
+  end
+
+  test "forwarded header cannot spoof loopback peer" do
+    get "/",
+        headers: { "X-Forwarded-For" => "127.0.0.1" },
+        env: { "REMOTE_ADDR" => "203.0.113.9" }
+
+    assert_redirected_to "/login"
+  end
+
+  test "non-loopback configured bind disables bypass" do
+    Rails.application.config.x.hive_web_bind = "0.0.0.0"
+
+    get "/", env: { "REMOTE_ADDR" => "127.0.0.1" }
+
+    assert_redirected_to "/login"
+  end
+end
diff --git a/web/test/integration/production_host_authorization_test.rb b/web/test/integration/production_host_authorization_test.rb
new file mode 100644
index 00000000..b9f7766d
--- /dev/null
+++ b/web/test/integration/production_host_authorization_test.rb
@@ -0,0 +1,32 @@
+require "test_helper"
+require "action_dispatch"
+require "rack/mock"
+require "hive/web/host_authorization"
+
+class ProductionHostAuthorizationTest < ActiveSupport::TestCase
+  def authorized_app(bind: "127.0.0.1", origin: nil)
+    hosts = Hive::Web::HostAuthorization.allowed_hosts(bind: bind, origin: origin)
+    ActionDispatch::HostAuthorization.new(
+      ->(_env) { [ 200, { "content-type" => "text/plain" }, [ "ok" ] ] },
+      hosts
+    )
+  end
+
+  test "accepts any loopback address while rejecting an unconfigured host" do
+    app = authorized_app
+
+    assert_equal 200, Rack::MockRequest.new(app).get("/", "HTTP_HOST" => "127.0.0.2").status
+    assert_equal 403, Rack::MockRequest.new(app).get("/", "HTTP_HOST" => "attacker.example").status
+  end
+
+  test "non-loopback mode retains host authorization for the configured origin" do
+    app = authorized_app(bind: "0.0.0.0", origin: "https://hive.internal.example")
+
+    assert_equal 200,
+                 Rack::MockRequest.new(app).get("/", "HTTP_HOST" => "192.0.2.10").status
+    assert_equal 200,
+                 Rack::MockRequest.new(app).get("/", "HTTP_HOST" => "hive.internal.example").status
+    assert_equal 403,
+                 Rack::MockRequest.new(app).get("/", "HTTP_HOST" => "spoofed.example").status
+  end
+end
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index c84cb08e..013d17d8 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-23
 tags: [command, daemon, automation, json]
 ---
 
@@ -40,7 +40,7 @@ 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`   | Uses `Hive::Daemon::StatusReport` for process and service state, installed/current executable and version, readiness, and drift (`none`, `path`, `version`, `unparseable`, `unreadable`, or `not_applicable`). Exit code 0 only when running. The same report is consumed directly by Rails, without global stdout capture. |
 | `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. |
@@ -48,6 +48,12 @@ hive daemon queue   [list | show <id> | prune]  [--json]
 | `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". |
 
+The local status page exposes only fixed, confirmed `repair` and `restart`
+POST actions. Repair calls the current service installer directly, so it works
+even when the daemon queue is down; arbitrary action names and command
+arguments are never accepted. The managed web service preserves
+`HIVE_INVOKED_BIN`, keeping repair aligned with the exact CLI used by setup.
+
 ## Global Digest
 
 Daily digest scheduling is global config, not project enrollment:
diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md
new file mode 100644
index 00000000..bd430f78
--- /dev/null
+++ b/wiki/commands/setup.md
@@ -0,0 +1,45 @@
+---
+title: hive setup
+type: command
+source: lib/hive/commands/setup.rb, lib/hive/setup/, schemas/hive-setup.v1.json
+created: 2026-07-23
+updated: 2026-07-23
+tags: [command, setup, diagnostics, web, daemon]
+---
+
+**TLDR**: `hive setup` is the idempotent Linux/macOS local-mode orchestrator.
+It diagnoses prerequisites, bootstraps only Hive-owned QMD and the matching
+Rails bundle, installs the daemon and web as separate per-user services using
+the exact invoked binary, enrolls the current repository, and waits for
+`http://127.0.0.1:4567/health`.
+
+## Phases
+
+The fixed order is diagnostics, QMD bootstrap, verified web bundle, daemon
+install/readiness, repository enrollment, web install/readiness. A mandatory
+diagnostic failure prevents service mutations. Each completed safe phase is
+retained so rerunning setup can resume without destructive initialization or
+unnecessary service churn.
+
+Diagnostics cover supported Linux/macOS, Ruby 3.4, git, tmux, Node/npm, SQLite,
+cosign, QMD, Rails bundle state, and installed/authenticated `gh`, Claude, and Codex.
+Every probe is timeout-bounded. Setup never installs or authenticates
+operator-owned tools; failure results contain exact remediation.
+Nested initialization, service, Bundler, and Rails output is isolated from
+stdout under `--json`, leaving one schema-valid document.
+
+## Options
+
+- `--no-bootstrap` reports missing Hive-owned dependencies without installing.
+- `--no-init` skips current-repository initialization/enrollment.
+- `--no-service` prepares local mode but does not install/start the web user
+  service; the result prints the exact `hive web` foreground command.
+- `--json` emits the `hive-setup.v1` phase/check envelope and uses the same
+  overall success decision as the process exit.
+
+Windows is explicitly unsupported. A host without systemd-user or launchd can
+finish all foreground prerequisites, but default setup does not claim managed
+readiness; it returns the exact fallback
+`hive daemon start --detach && hive web`.
+
+See [[commands/web]], [[commands/daemon]], [[operating]], and [[testing]].
diff --git a/wiki/commands/web.md b/wiki/commands/web.md
index f6f25373..f607d797 100644
--- a/wiki/commands/web.md
+++ b/wiki/commands/web.md
@@ -3,13 +3,16 @@ 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
+updated: 2026-07-23
 tags: [command, web, hivebox, rails, turbo]
 ---
 
-**TLDR**: `hive web` boots the hivebox web UI — a vanilla **Rails 8** app
-(importmap, Turbo, Stimulus, propshaft, solid_cable) living in `web/` at the
-repo root, shipped in the Docker image at `/app/web`. The web tier adds no
+**TLDR**: `hive web` boots Hive's **Rails 8** UI locally from a
+version-matched managed release bundle, while retaining source-checkout and
+Docker/hivebox execution modes.
+`hive web install|start|stop|status` manages a distinct systemd-user/launchd
+service. Local mode shares the CLI/TUI/daemon XDG configuration and real
+repositories; Docker remains the `/data`-isolated alternative. The web tier adds no
 pipeline logic: status reads call `Hive::Commands::Status#json_payload` (via
 `Hive::Web::StatusFeed`), gate approval calls `Hive::Commands::Approve`
 in-process, task Drop calls `Hive::Commands::Drop` in-process, stage runs go
@@ -22,17 +25,35 @@ path with separate gates.
 
 ## 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 [--bind] [--port] [--unsafe]` is always foreground. It uses an
+in-tree app when present and otherwise verifies/installs the matching release
+bundle under `Hive::Paths.data_home/web`. `hive web install|start|stop|status`
+operates the independent user service and reports running separately from HTTP
+readiness. The command 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).
+`HIVE_WEB_STORAGE_DIR`/legacy `HIVEBOX_STORAGE_DIR` (the solid-stack SQLite
+files under `Hive::Paths.state_home/web-storage`), runs `bin/rails db:prepare`,
+then execs `bin/rails server`. Canonical `HIVE_WEB_*` variables win over
+legacy `HIVEBOX_*` aliases. The gem remains lean; releases publish
+`hive-web-<version>.tar.gz` beside the gem and signed checksum metadata.
+Default acquisition fails closed unless cosign authenticates that checksum
+manifest. Custom acquisition is available only through the paired
+`HIVE_WEB_BUNDLE_URL` and `HIVE_WEB_BUNDLE_SHA256` variables. Download and
+bundle/assets/database preparation all have hard deadlines, and reuse requires
+a complete executable bundle rather than only a matching version marker.
+
+## Local security
+
+The default bind is `127.0.0.1:4567`. Login bypass requires local-loopback mode,
+a configured loopback bind, and an actual loopback request peer; forwarding
+headers cannot manufacture the bypass and Rails Host authorization remains
+active for the full loopback range and explicitly configured bind/origin. A
+non-loopback bind is refused unless the GitHub device-flow client is configured
+or the operator explicitly supplies `--unsafe`; an ownerless installation with
+the client configured remains claimable on first login. Unsafe mode remains
+visible in startup and status warnings.
 
 ## Auth
 
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 2d71cc61..781a2acd 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -47,6 +47,16 @@ Latest refresh note (2026-06-16): the babysitter gh-hostname dry-run audit remai
 
 ## Open questions about the codebase
 
+### 2026-07-23 local web platform verification
+
+The managed bundle, loopback policy, systemd-user/launchd rendering, setup
+orchestration, drift reporting, and installed-artifact boot have automated
+source/fixture coverage. The release gate boots the built gem and matching web
+artifact on Linux and macOS. There is not yet an in-repository artifact from a
+clean interactive user account proving both real service managers through the
+entire install/start/status/repair/stop/uninstall sequence, nor a published
+release asset for version 0.3.2 at implementation time.
+
 ### 2026-06-22 dependency-stacking placeholder branch investigation
 
 Branch-creator inventory for the U1-U10 inversion dogfood found no separate
diff --git a/wiki/index.md b/wiki/index.md
index d59b93e3..302d3ce3 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -3,15 +3,15 @@ title: hive Wiki
 type: index
 source: wiki/**/*.md
 created: 2026-05-14
-updated: 2026-06-25
+updated: 2026-07-23
 tags: [index, wiki]
 ---
 
 
 **TLDR**: Catalog of the LLM-maintained wiki for `hive`.
 
-Page count: 84
-Updated: 2026-06-25
+Page count: 85
+Updated: 2026-07-23
 
 Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descriptor-backed workflows move task folders through filesystem stages, stage agents run via configurable AgentProfile CLIs (`claude` default, `codex`, `pi`), and `mv` between directories remains the approval primitive. The built-in `coding` workflow drives the nine-stage PR pipeline (`1-inbox` → `2-brainstorm` → `3-plan` → `4-execute` → `5-open-pr` → `6-review` → `7-artifacts` → `8-finalize` → `9-done`), while `content` and project-authored workflows share the same generic runner/status/action machinery. The public release surface is the `hive-cli` rubygem installed through Homebrew, AUR, or `install.sh`, with `hv` as the Apache Hive collision fallback entrypoint, plus the hivebox GHCR Docker image and one-command `hivecli.sh/box` shell / `hivecli.sh/box.ps1` PowerShell installers; `hive web`/hivebox, `hive init` workflow selection and normal-vs-patrol reviewer split, project-global Claude model/effort pins, `hive connect screenote` for OAuth-backed Screenote MCP uploads, `hive patrol` handoff into `6-review`, `hive babysit`, `hive bench submit` for hive-bench corpus submissions, `hive digest` for the daily shipped digest, and the single ClawHub `hive-cli` listing that installs the OpenClaw `/hive` skill are covered by dedicated command/module pages.
 
@@ -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/20260723-local-web-install.md b/wiki/log.d/20260723-local-web-install.md
new file mode 100644
index 00000000..87d22a71
--- /dev/null
+++ b/wiki/log.d/20260723-local-web-install.md
@@ -0,0 +1,11 @@
+## 2026-07-23 — First-class local Hive web install
+
+- Added `hive setup` diagnostics and idempotent Linux/macOS orchestration.
+- Added verified, version-matched Rails release bundles with safe staged
+  extraction and XDG-local dependency/state separation.
+- Added foreground `hive web` plus independent systemd-user/launchd
+  install/start/stop/status lifecycle at `127.0.0.1:4567`.
+- Added loopback peer enforcement, non-loopback auth/unsafe policy, daemon
+  binary/version drift reporting, and fixed repair/restart web actions.
+- Added installed-artifact release gates, shared-state E2E coverage, schemas,
+  operator docs, and Docker/hivebox compatibility coverage.
diff --git a/wiki/log.d/20260723T213750Z-local-web-review-hardening.md b/wiki/log.d/20260723T213750Z-local-web-review-hardening.md
new file mode 100644
index 00000000..018d9cd9
--- /dev/null
+++ b/wiki/log.d/20260723T213750Z-local-web-review-hardening.md
@@ -0,0 +1,18 @@
+---
+date: 2026-07-23
+slug: local-web-review-hardening
+---
+
+- Hardened [[commands/web]] release acquisition with mandatory cosign
+  authentication, bounded downloads/preparation, complete-bundle reuse checks,
+  canonical storage precedence, and source/Docker-compatible Bundler behavior.
+- Made managed web status reflect the installed definition and distinguish
+  manager, unit, process, port, HTTP, and configuration-drift failures.
+- Added safe shared daemon repair/restart primitives, active-agent refusal,
+  persisted maintenance results, and action-specific status UI guidance.
+- Made [[commands/setup]] enrollment use the registered project name, isolated
+  JSON stdout, added phase remediation, rediscovered Hive-managed QMD, and
+  documented the exact no-service-manager foreground fallback.
+- Expanded package and E2E acceptance to exercise real HTTP visibility,
+  automatic daemon pickup, native service lifecycle, maintenance, and safe
+  uninstall.
diff --git a/wiki/operating.md b/wiki/operating.md
index 2b24d46a..20f43edd 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -3,7 +3,7 @@ title: Operating Hive
 type: operating
 source: README.md, bin/hv, install.sh, lib/hive/commands/daemon.rb, lib/hive/commands/babysit.rb, lib/hive/commands/bot.rb, examples/systemd/, examples/launchd/, openclaw/skills/hive/SKILL.md, openclaw/README.md
 created: 2026-05-07
-updated: 2026-06-25
+updated: 2026-07-23
 tags: [operating, daemon, bot, systemd, launchd, install]
 ---
 
@@ -94,6 +94,41 @@ Fresh installs use XDG locations:
 | Cache | `~/.cache/hive/` |
 | User binary symlink | `~/.local/bin/hive` |
 
+## First-class local web
+
+From the repository to enroll:
+
+```bash
+hive setup
+hive web status
+```
+
+The default setup installs the separate daemon and web user services and waits
+for `http://127.0.0.1:4567/health`. It uses the invoked Hive binary in both
+definitions. Bare `hive web` is foreground; managed lifecycle is:
+
+```bash
+hive web install
+hive web start
+hive web status --json
+hive web stop
+hive daemon repair --json
+hive daemon restart --json
+```
+
+Immutable web files live in `${XDG_DATA_HOME}/hive/web` and
+`${XDG_DATA_HOME}/hive/web-gems`; SQLite and mutable Rails state live in
+`${XDG_STATE_HOME}/hive/web-storage`. `hive uninstall` removes the managed app
+and dependencies but preserves storage. Only `--force-purge-state` deletes it.
+If web service deregistration fails, uninstall preserves the runtime as well,
+so a still-loaded unit cannot enter a missing-executable crash loop.
+
+Local login bypass is loopback-only and verifies the actual peer. A public bind
+requires a configured GitHub device-flow client or explicit `--unsafe`; a fresh
+ownerless installation with that client remains claimable. Host authorization
+stays enabled in every mode. Docker/hivebox continues to use `/data` and the
+lower-priority `HIVEBOX_*` compatibility variables.
+
 `HIVE_HOME` remains a legacy/test override. Project state stays at
 `<project>/.hive-state/`; install and uninstall do not move completed pipeline
 work.
diff --git a/wiki/testing.md b/wiki/testing.md
index 3ee77fea..78faab6e 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -3,7 +3,7 @@ title: Testing
 type: reference
 source: test/, Rakefile, bin/hive-eval, .rubocop.yml, .github/workflows/ci.yml, .github/workflows/release.yml, config/brakeman.ignore
 created: 2026-04-25
-updated: 2026-06-25
+updated: 2026-07-23
 tags: [test, minitest, fixtures]
 ---
 
@@ -15,6 +15,27 @@ tags: [test, minitest, fixtures]
 bundle exec rake test
 ```
 
+The local web artifact gate runs:
+
+```bash
+packaging/smoke-local-web.sh
+```
+
+It builds and installs the gem outside the checkout, creates or consumes the
+matching Rails archive, runs real bundle/assets/database preparation with
+XDG-local dependencies and storage, boots foreground `hive web`, checks
+`/health`, then runs `hive setup --no-init` through the native user service
+manager, checks readiness, exercises daemon repair/restart, stops web, and
+verifies uninstall removed the units/runtime. Release CI runs the same script
+on Linux/systemd-user and macOS/launchd against the exact build artifacts
+before publication.
+
+`test/e2e/scenarios/local_web_shared_state.yml` pins that the CLI and Rails
+observe the same registered project/task tree through a real HTTP request, then
+starts the daemon and waits for its automatic dispatch event. Docker/hivebox
+tests remain independent regression gates for `/data`, legacy environment
+aliases, authentication, and Host authorization.
+
 ## Coverage
 
 ```bash
