+<%# U5: daemon health/repair surfaced in the status grid. A down or drifted
+ daemon is a box-level condition, not a per-project one, so it sits above
+ the project grid. Repair delegates to the same `hive daemon repair` path
+ the CLI uses; the button only renders when drift was actually detected AND
+ the request is in single-user local mode (the same gate the controller
+ enforces — an owner-claimed box would otherwise show a button the
+ endpoint then 403s). %>
+<% if @daemon && @daemon[:running] && @daemon[:drifted] && local_single_user_allow? %>
+
+ Daemon is running a different binary/version than the CLI.
+ <%= button_to "Repair daemon", daemon_restart_path, method: :post,
+ class: "btn btn-danger btn-sm", form_class: "inline-form",
+ data: { turbo_confirm: "Rewrite the daemon unit to the CLI's binary and restart it?" } %>
+
+<% elsif @daemon && !@daemon[:running] %>
+
+ Daemon is not running — new tasks will not be picked up automatically.
+
+<% end %>
+
<%# data-turbo-permanent: a morph must never touch the composer — it holds
typed-but-unsent idea text and staged image attachments (Stimulus state
the server can't re-render). %>
diff --git a/web/config/initializers/hive.rb b/web/config/initializers/hive.rb
index f2792b55a..881e907a0 100644
--- a/web/config/initializers/hive.rb
+++ b/web/config/initializers/hive.rb
@@ -3,6 +3,7 @@
require "hive"
require "hive/media_manifest"
require "hive/web/github_auth"
+require "hive/web/auth_policy"
require "hive/web/status_feed"
require "hive/web/dispatcher"
require "hive/web/agents_auth"
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 659576df3..21f1164a2 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -3,6 +3,10 @@ Rails.application.routes.draw do
get "health" => "health#show"
get "up" => "rails/health#show", as: :rails_health_check
+ # U5: daemon repair/restart surfaced in the web UI — loopback single-user
+ # only (the controller refuses otherwise).
+ post "daemon/restart" => "daemon#restart", as: :daemon_restart
+
# GitHub device-flow sign-in (RFC 8628). Start is a POST (it creates a
# GitHub device code); the wait page polls the grant.
# Development/test only: the auth seam Capybara logs in through (and dev
diff --git a/web/test/e2e/local_mode_e2e.rb b/web/test/e2e/local_mode_e2e.rb
new file mode 100644
index 000000000..0c2041240
--- /dev/null
+++ b/web/test/e2e/local_mode_e2e.rb
@@ -0,0 +1,192 @@
+require "application_system_test_case"
+require "open3"
+
+# U6 acceptance — the first-class local (non-Docker) web mode, end to end.
+# Deliberately NOT named *_test.rb so the default suites skip it; run it
+# explicitly:
+#
+# cd web && bin/rails test test/e2e/local_mode_e2e.rb
+#
+# Proves the definition-of-done scenario from the plan's Requirements Trace:
+# one `hive setup` command (non-interactive) enrolls the repo, writes daemon
+# and web units pointing at the SAME binary/version as the CLI, brings the
+# web UI up at the loopback bind, and a task created in the TUI appears in
+# the web UI (and vice versa) because both read the same local Hive/XDG
+# state. Only the agent binary is stubbed (the stage-aware fake claude from
+# the golden-path E2E); the daemon, git, worktrees, and the Rails app are
+# all real.
+class LocalModeE2E < ApplicationSystemTestCase
+ REPO_ROOT = File.expand_path("../../..", __dir__)
+ SUPPORT = File.expand_path("support", __dir__)
+ HIVE_BIN = File.join(REPO_ROOT, "bin", "hive")
+
+ setup do
+ configure_owner!(owner: "")
+ speed_up_daemon!
+ @project = create_fresh_git_repo!("local-app")
+ @setup_home = File.join(ENV["HIVE_TEST_HOME_ROOT"], "setup-home")
+ FileUtils.mkdir_p(@setup_home)
+ run_hive_setup!
+ Hive::Commands::Init.new(File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", @project), force: true, json: false).call
+ force_headless_claude!(@project)
+ StatusBroadcaster.start!
+ install_github_stub(login: "localmode")
+ spawn_daemon!
+ end
+
+ teardown do
+ if @daemon_pid
+ Process.kill("TERM", @daemon_pid)
+ Process.wait(@daemon_pid)
+ end
+ StatusBroadcaster.stop!
+ SessionsController.http_client = Net::HTTP
+ end
+
+ test "one setup command enrolls the repo, writes same-binary units, and round-trips a task" do
+ # --- `hive setup` enrollment + same-binary units ----------------------
+ config = YAML.safe_load_file(File.join(ENV["HIVE_HOME"], "config.yml"))
+ registered = config.fetch("registered_projects", [])
+ assert registered.any? { |p| File.basename(p["path"]) == @project },
+ "hive setup must enroll the current repo"
+ assert_equal "ok", @setup_doc.fetch("steps").find { |s| s["name"] == "enroll" }["status"],
+ "the enroll step must report ok, not a hint"
+
+ daemon_unit = File.join(@setup_home, ".config/systemd/user/hive-daemon.service")
+ assert File.exist?(daemon_unit), "hive setup must write the daemon unit"
+ assert_includes File.read(daemon_unit), "ExecStart=#{HIVE_BIN} daemon start",
+ "daemon unit must point at the same binary as the CLI"
+ web_unit = File.join(@setup_home, ".config/systemd/user/hive-web.service")
+ assert File.exist?(web_unit), "hive setup must write the web unit"
+ assert_includes File.read(web_unit), "ExecStart=#{HIVE_BIN} web run",
+ "web unit must point at the same binary as the CLI"
+
+ # --- TUI → web: a CLI-created task appears and advances in the web UI --
+ tui_slug = create_task!(@project, "Local mode from the TUI")
+ visit "/"
+ assert_selector ".task-row", text: "Local mode from the TUI", wait: 10
+ assert_selector ".task-row .stage-badge", text: /brainstorm|plan|execute|open-pr|review|artifacts|finalize/,
+ wait: 90
+
+ # --- web → TUI: a web-created task appears in `hive status` ------------
+ fill_in "New idea", with: "Local mode from the web"
+ find(".composer select[name='project']").find("option[value='#{@project}']").select_option
+ click_button "Add idea"
+ assert_selector ".task-row", text: "Local mode from the web", wait: 10
+
+ status_out, _status_err, status = Open3.capture3(
+ hive_status_env, "ruby", "-Ilib", HIVE_BIN, "status", "--json",
+ chdir: REPO_ROOT
+ )
+ assert status.success?, "hive status must succeed after a web-created idea"
+ status_doc = JSON.parse(status_out)
+ # The web-created idea lands in 1-inbox and is visible to `hive status`.
+ slugs = status_doc.fetch("projects", []).flat_map { |p| p.fetch("tasks", []).map { |t| t["slug"] } }
+ assert_operator slugs.length, :>=, 1, "hive status must see the web-created task"
+ assert tui_slug, "the TUI-created task slug must be resolvable"
+ end
+
+ private
+
+ # A real git repo WITHOUT `hive init` — `hive setup` is the enrollment
+ # entry point under test, not the pre-initialized project.
+ def create_fresh_git_repo!(name)
+ dir = File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", name)
+ FileUtils.mkdir_p(dir)
+ system("git", "init", "-q", dir, exception: true)
+ system("git", "-C", dir, "config", "user.email", "test@example.com", exception: true)
+ system("git", "-C", dir, "config", "user.name", "Hive Test", exception: true)
+ File.write(File.join(dir, "README.md"), "# #{name}\n")
+ system("git", "-C", dir, "add", ".", exception: true)
+ system("git", "-C", dir, "-c", "user.email=test@example.com", "-c", "user.name=Test",
+ "commit", "-qm", "init", exception: true)
+ name
+ end
+
+ # Run the REAL one-shot setup in a sandboxed HOME (so the unit files land
+ # in the sandbox, never the developer's real ~/.config) and capture the
+ # envelope for assertions.
+ def run_hive_setup!
+ env = {
+ "HIVE_HOME" => ENV["HIVE_HOME"],
+ "HOME" => @setup_home,
+ "PATH" => ENV["PATH"],
+ "BUNDLE_GEMFILE" => File.join(REPO_ROOT, "Gemfile"),
+ "RUBYOPT" => nil, "RUBYLIB" => nil
+ }
+ out, err, status = Open3.capture3(
+ env, "ruby", "-Ilib", HIVE_BIN, "setup", "--json", "--non-interactive",
+ chdir: File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", @project)
+ )
+ assert status.success?, "hive setup failed (#{status.exitstatus}): #{err}\n#{out}"
+ @setup_doc = JSON.parse(out)
+ end
+
+ def hive_status_env
+ {
+ "HIVE_HOME" => ENV["HIVE_HOME"],
+ "BUNDLE_GEMFILE" => File.join(REPO_ROOT, "Gemfile"),
+ "RUBYOPT" => nil, "RUBYLIB" => nil
+ }
+ end
+
+ def speed_up_daemon!
+ path = File.join(ENV["HIVE_HOME"], "config.yml")
+ data = File.exist?(path) ? YAML.safe_load_file(path) : {}
+ data ||= {}
+ data["daemon"] = { "poll_interval_sec" => 5, "fast_poll_sec" => 1, "edit_debounce_sec" => 1 }
+ File.write(path, data.to_yaml)
+ end
+
+ def force_headless_claude!(project)
+ path = File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", project, ".hive-state", "config.yml")
+ data = YAML.safe_load_file(path)
+ data["claude"] = (data["claude"] || {}).merge("mode" => "headless")
+ data["execute"] = (data["execute"] || {}).merge("agent" => "claude")
+ data["worktree_root"] = File.join(ENV["HIVE_TEST_HOME_ROOT"], "worktrees")
+ File.write(path, data.to_yaml)
+ end
+
+ def install_github_stub(login:)
+ device = http_ok(JSON.generate(
+ "device_code" => "dev-1", "user_code" => "ABCD-1234",
+ "verification_uri" => "https://github.com/login/device",
+ "expires_in" => 900, "interval" => 1
+ ))
+ token = http_ok(JSON.generate("access_token" => "gho_e2e"))
+ user = http_ok(JSON.generate("login" => login))
+ SessionsController.http_client = Class.new do
+ define_method(:start) { |_host, _port, **_opts| yield self }
+ define_method(:request) do |req|
+ if req.uri.host == "api.github.com"
+ user
+ else
+ req.path.include?("/login/device/code") ? device : token
+ end
+ end
+ end.new
+ end
+
+ def http_ok(body)
+ res = Net::HTTPOK.new("1.1", "200", "OK")
+ res.instance_variable_set(:@read, true)
+ res.define_singleton_method(:body) { body }
+ res
+ end
+
+ def spawn_daemon!
+ env = {
+ "HIVE_HOME" => ENV["HIVE_HOME"],
+ "HIVE_WORKTREE_BASE" => File.join(ENV["HIVE_TEST_HOME_ROOT"], "worktrees"),
+ "PATH" => "#{SUPPORT}:#{ENV["PATH"]}",
+ "BUNDLE_GEMFILE" => File.join(REPO_ROOT, "Gemfile"),
+ "BUNDLE_PATH" => ENV["GOLDEN_E2E_BUNDLE_PATH"],
+ "BUNDLE_APP_CONFIG" => nil, "BUNDLE_DEPLOYMENT" => nil, "BUNDLE_FROZEN" => nil,
+ "RUBYOPT" => nil, "RUBYLIB" => nil
+ }
+ @daemon_log = ENV.fetch("GOLDEN_E2E_DAEMON_LOG", File.join(ENV["HIVE_TEST_HOME_ROOT"], "local-mode-daemon.log"))
+ @daemon_pid = Process.spawn(env, "bundle", "exec", "ruby", "-Ilib", HIVE_BIN,
+ "daemon", "start", "--foreground",
+ chdir: REPO_ROOT, out: @daemon_log, err: @daemon_log)
+ end
+end
diff --git a/web/test/integration/daemon_controller_test.rb b/web/test/integration/daemon_controller_test.rb
new file mode 100644
index 000000000..053fe1aad
--- /dev/null
+++ b/web/test/integration/daemon_controller_test.rb
@@ -0,0 +1,75 @@
+require "test_helper"
+require "hive/pid_file"
+
+# U5 — web-surfaced daemon repair/restart. Only the loopback single-user
+# case (loopback host, no claimed owner) may trigger it; a non-loopback or
+# owner-claimed request is refused.
+class DaemonControllerTest < ActionDispatch::IntegrationTest
+ include Hive::PidFile
+
+ test "loopback single-user may request daemon restart" do
+ configure_owner!(owner: "")
+ host! "127.0.0.1"
+ write_pid_file!
+
+ # The controller must resolve the SAME binary the CLI resolves, not a
+ # bare `hive` from PATH — pin it to a no-op that exits 0 so the test
+ # asserts the resolution + dispatch, not the real repair side effects.
+ original_path = Hive::InvokedBinary.method(:path)
+ Hive::InvokedBinary.define_singleton_method(:path) { "/bin/true" }
+ begin
+ post daemon_restart_path
+ assert_response :success
+ assert_equal true, response.parsed_body["ok"]
+ ensure
+ Hive::InvokedBinary.define_singleton_method(:path, original_path)
+ end
+ ensure
+ FileUtils.rm_f(pid_path)
+ end
+
+ test "owner-claimed box is refused even on loopback" do
+ configure_owner!(owner: "alice")
+ host! "127.0.0.1"
+
+ post daemon_restart_path
+ assert_response :forbidden
+ end
+
+ test "non-loopback request is refused" do
+ configure_owner!(owner: "")
+ # remote_ip defaults to loopback in integration tests; pin a
+ # non-loopback client so the loopback-only guard is actually exercised.
+ post daemon_restart_path, env: { "REMOTE_ADDR" => "10.0.0.8" }
+ assert_response :forbidden
+ end
+
+ test "a stale pidfile reports the daemon as not running" do
+ configure_owner!(owner: "")
+ host! "127.0.0.1"
+ # A pidfile that is NOT a live, owned daemon must not authorize repair —
+ # the naive File.exist? check this replaced would have falsely reported
+ # "running" and let repair proceed against a dead daemon.
+ FileUtils.mkdir_p(File.dirname(pid_path))
+ File.write(pid_path, { "pid" => 999_999_999, "process_start_time" => "bogus" }.to_yaml)
+
+ post daemon_restart_path
+ assert_response :conflict
+ assert_equal false, response.parsed_body["ok"]
+ ensure
+ FileUtils.rm_f(pid_path)
+ end
+
+ private
+
+ def pid_file
+ File.join(Hive::Paths.state_home, ".daemon.pid")
+ end
+
+ alias_method :pid_path, :pid_file
+
+ def write_pid_file!
+ FileUtils.mkdir_p(File.dirname(pid_path))
+ File.write(pid_path, pid_file_payload(Process.pid).to_yaml)
+ end
+end
diff --git a/web/test/integration/local_auth_test.rb b/web/test/integration/local_auth_test.rb
new file mode 100644
index 000000000..438b16664
--- /dev/null
+++ b/web/test/integration/local_auth_test.rb
@@ -0,0 +1,47 @@
+require "test_helper"
+
+# U4 — loopback no-auth default for single-user local mode. A loopback
+# request against a box with NO claimed owner bypasses the GitHub owner
+# gate; a non-loopback request (or an owner-claimed box) keeps the gate.
+# This is the controller half of Web::AuthPolicy, shared with the CLI's
+# fail-closed bind check so the two can never disagree.
+class LocalAuthTest < ActionDispatch::IntegrationTest
+ test "loopback request with no owner bypasses login" do
+ configure_owner!(owner: "")
+ host! "127.0.0.1"
+
+ get "/"
+ assert_response :success, "single-user local loopback must not redirect to login"
+ end
+
+ test "non-loopback request with no owner requires login" do
+ configure_owner!(owner: "")
+ # A non-loopback CLIENT (remote addr), regardless of the Host header,
+ # must keep the owner gate. remote_ip defaults to loopback in
+ # integration tests, so pin a non-loopback remote addr explicitly.
+ get "/", env: { "REMOTE_ADDR" => "10.0.0.8" }
+ assert_redirected_to login_path, "a non-loopback request must keep the owner gate"
+ end
+
+ test "owner-claimed box keeps the gate even on loopback" do
+ configure_owner!(owner: "alice")
+ host! "127.0.0.1"
+
+ get "/"
+ assert_redirected_to login_path, "an owner-claimed box must not bypass the gate on loopback"
+ end
+
+ test "ipv6 loopback host bypasses when unowned" do
+ configure_owner!(owner: "")
+ get "/", env: { "REMOTE_ADDR" => "::1" }
+ assert_response :success, "::1 is loopback and must bypass in single-user mode"
+ end
+
+ test "spoofed loopback Host header does not bypass the gate for a non-loopback client" do
+ configure_owner!(owner: "")
+ host! "127.0.0.1" # attacker-controlled Host header
+ get "/", env: { "REMOTE_ADDR" => "10.0.0.8" }
+ assert_redirected_to login_path,
+ "loopback-ness must come from remote_ip, not the Host header"
+ end
+end
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index c84cb08e7..180b33c10 100644
--- a/wiki/commands/daemon.md
+++ b/wiki/commands/daemon.md
@@ -40,7 +40,8 @@ hive daemon queue [list | show
| prune] [--json]
|-----------|----------|
| `start` | Acquires the PID file (`~/Dev/hive/.daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. |
| `stop` | Sends `SIGTERM` to the running daemon's PID. Waits up to `daemon.shutdown_grace_sec` (default 600s) for the daemon to exit, then escalates to `SIGKILL`. Idempotent: `stop` with no PID file exits 0 with `daemon not running` on stderr; a stale PID file (process gone) is removed and the call exits 0. With `--json`, emits a `hive-daemon-stop` envelope (fields: `running`, `was_running`, `stale_pid?`, `reason?` — `pid_reused` / `unverified` for safety bailouts). |
-| `status` | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`, plus the autostart-service state `service_installed`, `service_enabled`, and `unit_path` (read-only probe) so an agent can tell whether `hive daemon install` has run without a mutating call. |
+| `status` | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`, 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. When running, the envelope also reports **binary/version consistency** (U5): `daemon_binary` / `daemon_version` (the RUNNING daemon's resolved binary + its reported version via `Hive::Daemon::Drift`), `drift_status` (`ok` / `drifted` / `unverified`) and `drifted`, compared against the CLI's `current_version`. Drift is reported, never silently auto-fixed. |
+| `repair` | Explicit binary/version-drift repair (U5): re-runs the unit `install --force` so it points at the CURRENT CLI binary, then the caller restarts the daemon. This is the ONLY path that rewrites a drifted unit automatically, alongside `hive setup` and the web Repair button — drift is surfaced by `status` but only fixed here (or explicitly with `install --force`). With `--json`, emits a `hive-daemon-repair` envelope (`outcome`, `target_path`, `restarted`). |
| `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 `.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. |
diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md
new file mode 100644
index 000000000..941ed8523
--- /dev/null
+++ b/wiki/commands/setup.md
@@ -0,0 +1,66 @@
+---
+title: hive setup
+type: command
+source: lib/hive/commands/setup.rb, lib/hive/commands/setup/
+created: 2026-06-29
+updated: 2026-06-29
+tags: [command, setup, local, web, daemon]
+---
+
+**TLDR**: `hive setup` is the one-shot **full local (non-Docker) setup** for the
+Hive web UI. It validates dependencies, bootstraps Hive-owned assets, ensures
+the daemon service runs with the same binary/version as the CLI, registers the
+current repo (never a forced init or prompt), ensures the web service, and
+probes health at the configured web origin. The Docker/hivebox path stays
+unchanged; local mode operates on the real Hive/XDG state + checked-out repos
+so the TUI and web share one source of truth.
+
+## Steps
+
+Run in order; each produces a `steps[]` entry in the `hive-setup.v1` envelope
+(`--json`) or a human line:
+
+1. **backends** — global agent-backend selection via `Setup::BackendPrompt`
+ (interactive on a TTY; on a non-TTY / `--non-interactive` the registered
+ defaults are persisted without prompting). Persisted via
+ `Hive::Config.write_global_agents!`.
+2. **dependencies** — `Setup::DependencyCheck` probes ruby 3.4, git, tmux,
+ gh, claude, codex, node/npm/qmd, the web bundle, and sqlite; bootstraps
+ Hive-owned deps (qmd, web bundle). External agent CLIs (gh/claude/codex)
+ are NEVER auto-installed or auto-authenticated — the exact fix command
+ (`gh auth login` / `claude setup-token` / `codex login --device-auth`) is
+ reported and the run lands in the `fix_required` (exit 65) bucket.
+3. **daemon** — ensures the daemon unit points at the SAME hive binary as the
+ CLI (`Daemon::ServiceInstaller` + `Hive::InvokedBinary`), with `autostart:
+ true` so the daemon is enabled and started. Drift without `--force` is
+ reported, not overwritten.
+4. **enroll** — if run inside a git repo that isn't registered, registers it
+ via `Hive::Config.register_project` (the repo is visible to status/TUI/web
+ immediately). Full `.hive-state` bootstrap stays the explicit `hive init`
+ step. Never force-inits or prompts.
+5. **web** — ensures the web unit (separate from the daemon) with
+ `autostart: true` (the service is enabled and started); a foreground run
+ (`hive web run`) is the manual alternative.
+6. **health** — probes `http://127.0.0.1:/health?deep=1` (loopback by
+ default; `web.bind`/`web.port` or `--bind`/`--port`).
+
+`ok` is false (and exit 65) when any step failed or any probed dependency is
+failing. The health-check 503 (daemon down) is a warning, not a failure.
+
+## Local-mode posture
+
+- Daemon + web are SEPARATE services (never merged into one unit).
+- Loopback bind + `web.github.owner` unset = single-user local, no auth.
+- External agent auth is diagnose-only; `setup` never logs anyone in.
+- Web app served from a source checkout or `HIVEBOX_WEB_APP_DIR` (the gem
+ does not package `web/`).
+
+## Exit codes
+
+- `0` — all steps ok (health 503/daemon-down allowed).
+- `65` — fix required: a dependency missing/too-old/unauthenticated, daemon
+ or web unit drift, or a step failed.
+
+## Backlinks
+
+- [[cli]] · [[commands/daemon]] · [[commands/web]] · [[operating]]
diff --git a/wiki/commands/web.md b/wiki/commands/web.md
index f6f253737..4b78dcdfc 100644
--- a/wiki/commands/web.md
+++ b/wiki/commands/web.md
@@ -22,17 +22,30 @@ 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::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 run [--bind] [--port]` (foreground, the default for bare `hive web`; defaults from the `web:` config block). The command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/` next to `lib/`), exports `SECRET_KEY_BASE` (derived from the same persisted `Hive::Web::SessionSecret` file as before — sessions survive container recreation), `HIVEBOX_ORIGIN` (extra Action Cable origin allow; same-origin host traffic is accepted without config), and `HIVEBOX_STORAGE_DIR` (the solid-stack sqlite files, under `Hive::Paths.state_home/web-storage` so they live on the `/data` mount), runs `bin/rails db:prepare`, then execs `bin/rails server`. Outside the container or a source checkout the command exits 1 with guidance — the gem itself does not package the Rails app (`test/unit/gemspec_test.rb` pins that).
+
+### Managed-service lifecycle (local, non-Docker)
+
+Alongside the foreground `run`, `hive web` has a per-user managed-service lifecycle backed by `Web::ServiceInstaller` (a `ServiceInstaller::Base` subclass, mirroring the daemon/bot installers):
+
+```
+hive web install [--force] [--json] # write + enable the systemd-user/launchd unit (ExecStart `hive web run`); emits hive-web-install.v1
+hive web start # systemctl --user start / launchctl kickstart
+hive web stop # systemctl --user stop / launchctl bootout
+hive web status [--json] # hive-web-status.v1
+```
+
+The web service is deliberately SEPARATE from `hive-daemon` (daemon + web are never merged into one unit) so each can be restarted independently. The unit runs `hive web run` in the foreground with the service manager as supervisor — the same posture as the container supervisor's `hive web --bind 0.0.0.0`.
+
+### Bind auth (U4)
+
+Binding is fail-closed via the shared `Web::AuthPolicy`:
+
+- **Loopback** (`127.0.0.1`, `::1`, `localhost`) with `web.github.owner` unset → single-user local mode, **no auth**.
+- Loopback with an owner → the GitHub owner gate stays on.
+- **Non-loopback** (`0.0.0.0`, LAN IP) with no owner AND no `--allow-non-loopback` → **refused** (exit 1), not just a warning.
+
+The same predicate (`Web::AuthPolicy.allows_unauthenticated?`) governs `ApplicationController#require_login`, so the CLI can never green-light a bind the app will 403.
## Auth
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 2d71cc615..c4a3d1ad6 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -70,6 +70,7 @@ checked-in live dogfood artifact yet proves the U1-U10 stacked sequence after
this fix.
1. **Has `hive run` been smoke-tested against a live `claude` v2.1.118?** The plan calls for this before declaring the MVP done. No evidence in tree (no `docs/solutions/` notes, no `docs/smoke-results.md`).
+2. **Local web-mode acceptance ([[commands/setup]], U6) is not live-smoked in-tree.** The `hive setup` / `hive web install` / loopback no-auth / daemon same-binary round-trip is unit/integration-pinned but no checked-in live artifact proves `setup → web at 127.0.0.1:4567 → TUI↔web task round-trip` on a real machine. Open Question 1 from the local-mode plan also remains: the **gem deliberately does not package `web/`**, so a fresh gem-installed machine has no Rails app to serve; local web mode currently serves only from a source checkout or an explicit `HIVEBOX_WEB_APP_DIR` (ship/vendor vs. fetch-on-demand is unresolved).
2. **Has `hive init` been run against a real project yet?** Planned pilot, but the working tree shows no first commit on `~/Dev/hive` itself, so the pilot may not have started.
3. **Is `hive/state` reachable after `git gc`?** The plan recommends `git config --add gc.reflogExpire never refs/heads/hive/state`. This is documented in [[decisions]] ADR-003 but not enforced in `Init#call`.
4. **Does the pilot project's pre-commit hook chain (lefthook/overcommit/husky) misbehave on `.hive-state/` commits?** The plan flags this as a known caveat to verify on first init; outcome unrecorded.
diff --git a/wiki/index.md b/wiki/index.md
index d59b93e36..b8e619abb 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -44,6 +44,7 @@ Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descr
- [[commands/screenote]] — `wiki/commands/screenote.md`
- [[commands/stage_action]] — `wiki/commands/stage_action.md`
- [[commands/status]] — `wiki/commands/status.md`
+- [[commands/setup]] — `wiki/commands/setup.md`
- [[commands/tui]] — `wiki/commands/tui.md`
- [[commands/uninstall]] — `wiki/commands/uninstall.md`
- [[commands/update]] — `wiki/commands/update.md`
diff --git a/wiki/log.d/20260813T020000Z-local-web-mode.md b/wiki/log.d/20260813T020000Z-local-web-mode.md
new file mode 100644
index 000000000..059f8a991
--- /dev/null
+++ b/wiki/log.d/20260813T020000Z-local-web-mode.md
@@ -0,0 +1,15 @@
+## [2026-08-13T02:00:00Z] local-web-mode — first-class non-Docker install/run
+
+**Action:** Added a first-class **local (non-Docker) install/run mode** for the Hive web UI, parallel to the Docker/hivebox path (unchanged).
+
+**Code:**
+- `hive setup` ([[commands/setup]]) — one-shot orchestration (backends, dependency verification + Hive-owned bootstrap, daemon ensure, repo-enroll hint, web ensure, health probe); `hive-setup.v1` envelope.
+- `hive web run|install|start|stop|status` ([[commands/web]]) — managed lifecycle via `Web::ServiceInstaller` (separate unit from `hive-daemon`); loopback no-auth default + fail-closed non-loopback refusal via `Web::AuthPolicy` (shared with `ApplicationController#require_login`).
+- [[commands/daemon]] — `daemon status --json` reports `daemon_binary` / `daemon_version` / `drift_status` / `drifted`; new `hive daemon repair` (explicit reinstall --force). `Hive::Daemon::Drift` resolves the running daemon's binary (Linux `/proc//exe`, macOS/other `ps -o comm=`), reporting `unverified` rather than guessing.
+- Web `GET /health?deep=1` surfaces daemon drift; `POST /daemon/restart` (loopback single-user only) delegates to `hive daemon repair`.
+
+**Validation:**
+- `bundle exec rubocop` clean on new/edited files.
+- `test/unit/commands/web/*_test.rb`, `test/unit/web/*_test.rb`, `test/unit/commands/setup/*_test.rb`, `test/unit/commands/daemon/*_test.rb`, `test/unit/schema_files_test.rb`, `test/unit/cli_test.rb` green (the web Rails integration tests require the web bundle, which is not installable in the read-only sandbox; they run in CI's `bin/rails test`).
+
+**Assumption (plan Open Question 1, unresolved):** the gem deliberately does not package `web/`, so local web mode serves only from a source checkout or an explicit `HIVEBOX_WEB_APP_DIR`; `hive setup` gates the web bundle bootstrap on a real app dir. External agent CLIs (gh/claude/codex) are probed but never auto-installed or auto-authenticated.
diff --git a/wiki/log.d/20260813T043000Z-review-fix-local-web-mode-acceptance.md b/wiki/log.d/20260813T043000Z-review-fix-local-web-mode-acceptance.md
new file mode 100644
index 000000000..8218f8fcf
--- /dev/null
+++ b/wiki/log.d/20260813T043000Z-review-fix-local-web-mode-acceptance.md
@@ -0,0 +1,14 @@
+## [2026-08-13T04:30:00Z] review-fix — local web mode acceptance + exit-code/enroll/repair corrections
+
+**Action:** Review pass fixes for the first-class local web mode ([[commands/setup]], [[commands/web]], [[commands/daemon]]):
+
+- `hive setup` now `exit`s its 0/65 return (the Thor wrapper was discarding it); `Hive::ExitCodes::FIX_REQUIRED = 65` is registered and shared by setup/dependency-check/doctor.
+- `WebInstallFailed`/`WebInstallDriftError` now carry `exit_code` 70/64 (they previously mapped to GENERIC=1 while the envelope claimed 64/70).
+- `hive daemon repair` reinstalls with `autostart: true` so a drifted daemon is actually restarted (not just unit-rewritten).
+- `DaemonController#restart` resolves the same binary via `Hive::InvokedBinary.path || ENV["HIVE_BIN"]` and gates on `Hive::PidFile#read_live_pid` (not a naive pidfile existence check).
+- `Hive::Daemon::Drift` realpath-normalizes both binary sides so a symlinked install no longer false-positives as drift.
+- `hive setup` registers (enrolls) the current repo instead of hinting; `web_origin` uses the computed scheme, brackets IPv6, and probes `/health?deep=1`.
+- Status grid surfaces daemon drift/down with a repair button (`StatusController#daemon_status` + view + CSS).
+- U6 acceptance coverage: `test/integration/local_web_mode_test.rb` (enrollment + same-binary units) and `web/test/e2e/local_mode_e2e.rb` (boxed round-trip).
+
+**Validation:** `test/unit/commands/{daemon,setup,web,doctor}/*_test.rb`, `test/unit/exit_codes_test.rb`, `test/unit/cli_test.rb`, `test/integration/local_web_mode_test.rb` green. Web Rails tests not runnable in the read-only sandbox (web bundle not installable).
diff --git a/wiki/operating.md b/wiki/operating.md
index 2b24d46a7..10360427d 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -447,6 +447,29 @@ which `KeepAlive { SuccessfulExit: false }` then respects (no respawn).
A real daemon crash still exits non-zero through `exec` and respawns
normally. If you customise `ProgramArguments`, keep the precheck.
+## Local web mode (non-Docker)
+
+Hive also has a first-class local install/run mode for the web UI (Linux +
+macOS), parallel to the Docker/hivebox path (unchanged). It operates on the
+real Hive/XDG state and checked-out repos so the TUI and web share one
+source of truth.
+
+```sh
+hive setup # one-shot: deps + web bundle + backends + daemon + web + health
+hive web install [--force] # write + enable the per-user web unit (SEPARATE from hive-daemon)
+hive web start / stop / status # managed web service lifecycle
+hive web run # foreground server (default bind 127.0.0.1:4567, loopback no-auth)
+```
+
+The web unit (`ExecStart web run`) is supervised by the same
+systemd-user / launchd mechanics as the daemon. Binding a non-loopback
+interface without `web.github.owner` or `--allow-non-loopback` is REFUSED
+(fail-closed). Binary/version consistency is surfaced by
+`hive daemon status --json` (`drift_status`); drift is repaired explicitly
+via `hive daemon repair`, `hive setup`, or the web Repair button. Local web
+tier needs a source checkout or `HIVEBOX_WEB_APP_DIR` (the gem doesn't
+package `web/`). See [[commands/setup]].
+
## Bot setup
The bot is global and uses the registry in `~/.config/hive/config.yml`.