#!/bin/sh # install-rust.sh — one-command installer for the Rust build of Prime Agent. # # THE KEYWORD TAKEOVER: the Rust port installs under the product keyword # PRIME-AGENT — the launcher lands at ~/.local/bin/prime-agent, the payload # at ~/.local/share/prime-agent/, and users type `prime-agent`. The old # prime-agent-rust layout (a TS-safe side-by-side name) is migrated: the # share tree moves to the new name with a one-generation .old rollback, and # this script's own launcher replaces the prime-agent-rust one it wrote in # past installs. The FILENAME stays install-rust.sh deliberately: the # curl|sh URL and the update entry points (`prime-agent update`, the TUI # /update — both exec this script with --update) already point at it, and # the filename is invisible to users; the command they type is what # changed. # # SOURCE: the R2-backed release channel (this repo file is the source the # release workflow uploads to the bucket as install.sh / install-beta.sh; # the channel pointers and manifests it reads - latest.json / beta.json, # the tarballs, SHA256SUMS - all come from the same download base). The # workflow-artifact bootstrap channel is retired; the user path never # touches GitHub. # # THE TYPESCRIPT TAKEOVER (this script is also the uninstall path for the # TS product — one installer owns the keyword's lifecycle): # 1. BOTH DAEMONS ARE STOPPED CLEANLY, NEVER KILLED (--force alone may # signal one that will not stop), but only AFTER the new payload and # launcher are published — the retirement steps (daemon stops, npm # uninstall) never leave the machine without a # working prime-agent if the install aborts mid-way. The probed # candidates: the TS daemon's default socket # (${TMPDIR:-/tmp}/prime-agent-$(id -u)/daemon.sock), # PRIME_AGENT_DAEMON_SOCKET when a profile exported it (the # non-default socket the first field install had), and this # product's own pinned rust socket. The daemon that answers a # candidate is classified by IDENTITY first — a hello whose # runtime build id is this product's ("pa-daemon-rs-", # carried since the port) is OUR daemon of any build (the update # flow: the running daemon holds the old binary and venv state, the # update needs it down, the next invocation boots the new daemon — # the summary's next-step line says so) — then by the protocol-7 # SCHEMA FAMILY: the family prefix minus this product's own hash is # the TypeScript daemon of ANY version (the second field install # answered schema-30-f908f493c9e1 where the last-known id was # schema-29-a5c9d20f8b13; an exact-hash check left it running); the # pinned rust socket is ours by construction, no classification at # all (an installer older than the daemon build must still stop # it). THE SELF-SOCKET REFUSAL: a candidate that equals the daemon # DRIVING this very install (the internal supervisor-socket # variable a daemon exports to its workers) is never probed at all # — the suite that killed the fleet daemon twice ran exactly that # shape — unless PRIME_AGENT_STOP_LIVE_DAEMON=1 overrides for a # deliberate in-daemon update (--force does not); an operator shell # never carries the internal variable, so the field contract is # unchanged. # Every daemon so identified is shut down REGARDLESS of busy-ness (the # field ruling: an install that leaves the old daemon up is the # takeover bug), by an ESCALATING ladder that sends no signal to any # pid without --force: the graceful `shutdown` request (force:false), a 5s # confirm poll, then the forced request (force:true) and a # DRAIN-SCALED second poll (2s per reported session over a 5s # floor — the field measured a 46-worker drain at ~49s, and a fixed # 5s poll reported the dying daemon as a stop-failure) — every # request is the daemon's own shutdown over its socket, and the # sessions' state stays on disk for the Rust side. The install then # VERIFIES every stopped socket is down (the socket stops # answering) and reports what was stopped in the summary; a daemon # that would not go down — or answers an unrecognized schema, or # will not greet — is a LOUD warning naming it and the manual stop # command (never a silent nothing-was-done), and a machine with no # daemon anywhere says so (every candidate is reported — no silent # skips). An unreadable session count never skips the stop. # 2. THE TS FILES ARE PRESERVED, NOT DELETED (the pi_agent_rust # `legacy-pi` precedent: rollback stays possible). What the TS # installer actually created, researched from its install.sh: a # managed root at ${XDG_DATA_HOME:-~/.local/share}/prime-agent # (marked by .managed = prime-agent-native-v1, holding releases// # trees and its own bin/prime-agent symlink), a public # ~/.local/bin/prime-agent symlink into it, an optional global npm # package, and an optional standalone node at # ~/.local/share/prime-agent-node. The takeover: # - a TS managed root occupying THIS script's target # ($PREFIX/share/prime-agent) moves to # $PREFIX/share/prime-agent-legacy (kept verbatim; rollback = # rename back and re-link the public bin symlink); # - the public ~/.local/bin/prime-agent is REPLACED by this # script's launcher (the keyword is ours now); # - the TS npm package is uninstalled (exact package `prime-agent` # only; the restore command is printed with the recorded version); # - the standalone node dir is LEFT in place (it is a runtime, not # the product binary/package — the user can remove it by hand). # 3. WHY DAEMON CONFLICTS ARE GONE AFTER THIS INSTALL: the launcher # pins a rust-only daemon socket # (${TMPDIR:-/tmp}/prime-agent-rust-$(id -u)/daemon.sock) — a # different path than the TS daemon's own — so BY DEFAULT this CLI # and the TS daemon never meet (each product's default socket is its # own), and the install-time stop-when-idle above retires a TS daemon # cleanly instead of orphaning one. Explicit overrides can still point # anywhere (--daemon-socket > PRIME_AGENT_DAEMON_SOCKET > the pinned # default — the product's documented contract; a foreign-schema daemon # behind an override reads as stale, the same behavior in both # products). Default pin + clean stop: out of the box, the two # daemons cannot fight over a socket after install. # Together with the kernel pre-warm below (uv + the Python venv at # install time), a fresh install's FIRST session works out of the # box, online or offline. # # THE SHARED STORE IS NEVER TOUCHED: ~/.prime/agent/ (sessions, leases, # config) is read and written by BOTH products by design — the same # sessions appear in either — and this installer never creates, renames, # migrates, or deletes anything under it. guard_preserved() aborts the # install if any computed path (prefix, bin, share, stage, rollback) # falls under the store. # # Config (env with defaults): # PRIME_AGENT_DOWNLOAD_BASE_URL the R2-backed download base (default: # the official domain, the same base the # release pipeline renders into the # published copy of this script) # PRIME_AGENT_RELEASE_CHANNEL stable | beta (default: stable) # PRIME_AGENT_VERSION pin an exact version instead of reading # the channel pointer # PRIME_AGENT_RUST_PREFIX install prefix (default: ~/.local; the # launcher lands at $PREFIX/bin/prime-agent, # the payload at $PREFIX/share/prime-agent/) # # THE CHANNEL (the R2 form, the TS install.sh parity): the script reads # the channel pointer (/stable or /beta) for the version, # reads the channel manifest (/latest.json or /beta.json) for # this platform's artifact row, fetches the tarball + SHA256SUMS from # /releases/v/, and verifies the checksum before # extraction. NO GITHUB SURFACE anywhere in the user path: no gh, no # GITHUB_TOKEN, no workflow-artifact API — the download base is the # R2-backed domain, and everything the installer reads comes from it. # # Usage: install-rust.sh [--update] — both entry points install the # channel's current version; the script is idempotent (a re-run replaces # the payload, keeps one .old rollback generation, and re-runs the # takeover steps as no-ops when there is nothing left to take over). # # PREREQUISITES: curl + sh (+ the network for the download). The installer # needs a Python for its own scripting steps (the store guard's realpath, # the channel manifest's JSON parsing, the daemon probe) — but it does # NOT need one installed: it bootstraps uv first (a static binary whose # installer needs only curl + sh) and resolves its Python THROUGH uv (an # existing interpreter when there is one, else uv's own managed 3.11 — # no system python3 anywhere). Offline without uv, a system python3 is # the fallback; with neither, the installer names the one missing # prerequisite and exits. set -eu # The publish-rendered defaults: the release pipeline copies this script # to /install.sh with DOWNLOAD_BASE_URL_DEFAULT set to # vars.R2_PUBLIC_BASE_URL, and to /install-beta.sh with the channel # default set to beta — the repo-file default IS the official domain, so # the raw repo copy installs out of the box too. DOWNLOAD_BASE_URL_DEFAULT="https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev" RELEASE_CHANNEL_DEFAULT="stable" BASE_URL="${PRIME_AGENT_DOWNLOAD_BASE_URL:-$DOWNLOAD_BASE_URL_DEFAULT}" CHANNEL="${PRIME_AGENT_RELEASE_CHANNEL:-$RELEASE_CHANNEL_DEFAULT}" PREFIX="${PRIME_AGENT_RUST_PREFIX:-$HOME/.local}" die() { echo "install-rust.sh: $1" >&2; exit 1; } usage() { cat <<'USAGE' install-rust.sh — install the Rust build of Prime Agent under the prime-agent keyword. The launcher lands at $PRIME_AGENT_RUST_PREFIX/bin/prime-agent and the payload at $PRIME_AGENT_RUST_PREFIX/share/prime-agent/ (default prefix ~/.local). The installed TypeScript product is taken over: its daemon is stopped with its own shutdown request, its native install is preserved under share/prime-agent-legacy, and its npm package is uninstalled (the restore command is printed). ~/.prime/agent (the shared session store) is never touched. Both the default and --update install the channel's current version. Needs curl + sh (+ the network): the installer bootstraps its own Python via uv — no system python3 required. It also pre-warms the Python kernel (uv + `prime-agent --prime-agent-bootstrap`); offline, that step degrades to a warning and the first session bootstraps the kernel itself — it needs the network once. Options: --update the documented alias the update entry points exec (identical run) --force if a Prime Agent daemon (TypeScript or Rust) is still up after the shutdown requests and the drain wait, send it SIGTERM, then SIGKILL. Never applies to the daemon this install runs under. (from a pipe: curl -fsSL /install.sh | sh -s -- --force) --verbose the detail also goes to stdout, not just fd 3 (no animation) Output: stderr the title, one line per step, warnings with the command that fixes them, and the ending; on a terminal an animated banner, a spinner, and a download bar (NO_COLOR or TERM=dumb: plain) stdout nothing by default; --verbose prints the detail here fd 3 the detail — a wrapper captures it by opening fd 3; a bare run leaves it closed Environment: PRIME_AGENT_DOWNLOAD_BASE_URL the R2-backed download base (the official domain by default) PRIME_AGENT_RELEASE_CHANNEL stable | beta (stable by default) PRIME_AGENT_VERSION pin an exact version (skips the channel pointer read; the manifest + SHA256SUMS checks still run) PRIME_AGENT_RUST_PREFIX install prefix (~/.local by default) PRIME_AGENT_RUST_VERBOSE 1 = the --verbose output mode USAGE } # --- arguments --------------------------------------------------------------- # No positional arguments. --update is the documented alias the update entry # points (`prime-agent update`, the TUI /update) exec — identical to the default # run because the flow is idempotent by construction. --verbose folds the fd-3 # progress detail onto stdout (PRIME_AGENT_RUST_VERBOSE=1 does the same). VERBOSE="${PRIME_AGENT_RUST_VERBOSE:-0}" FORCE=0 # Every argument is scanned (no positionals exist): the flags compose, so # `--update --verbose` sets both effects instead of silently dropping one. for arg in "$@"; do case "$arg" in --update) ;; --verbose|-v) VERBOSE=1 ;; --force) FORCE=1 ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown argument: ${arg}" ;; esac done # --- the output contract ------------------------------------------------------ # Everything a person reads goes to stderr, kept short: a title, one line # per step that did something ("✓ Installed"), warnings with the exact # command that fixes them, and a short ending. On a terminal one renderer # owns the screen while the install runs: an awk loop (below) that draws # the TUI's animated brand mark, the title, the finished steps, and the # active step's spinner (and the download bar), rewriting only the rows # that changed each 120ms tick; it freezes on its last frame when the # install ends. The install itself only writes the step state to files the # renderer reads. Captured output (CI, pipes, the TUI's /update) gets the # same lines with no colors, no animation, and no carriage returns, and a # failure's error stays the last stderr line (the update reports that # tail). The extra detail rides fd 3: a wrapper captures it by opening # fd 3, a bare run leaves it closed, and --verbose folds it onto stdout. if [ "$VERBOSE" = 1 ]; then exec 3>&1 elif ( : >&3 ) 2>/dev/null; then : # already open — a wrapper's capture channel; keep it (the subshell # probe tests the DESCRIPTOR, not a /dev/fd node: on macOS /dev/fd/3 # exists while fd 3 is closed, and a redirect onto a closed fd under # set -e would abort the installer at its first progress line) else exec 3>/dev/null fi UI_TTY=0 if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then UI_TTY=1; fi # The renderer redraws rows in place, so it stays off when --verbose # interleaves detail lines on the same terminal. UI_ANIM=0 if [ "$UI_TTY" = 1 ] && [ "$VERBOSE" != 1 ]; then UI_ANIM=1; fi UI_COLOR=0 if [ "$UI_TTY" = 1 ] && [ -z "${NO_COLOR:-}" ]; then UI_COLOR=1; fi # The prime theme's palette (crates/pa-types/themes/prime.json), 24-bit. C_BOLD="" C_DIM="" C_ACCENT="" C_WARN="" C_RESET="" if [ "$UI_COLOR" = 1 ]; then esc="$(printf '\033')" C_BOLD="${esc}[1m" C_DIM="${esc}[38;2;113;113;122m" C_ACCENT="${esc}[38;2;124;111;175m" C_WARN="${esc}[38;2;245;158;11m" C_RESET="${esc}[0m" fi SAY_DIM="" SAY_RESET="" if [ "$VERBOSE" = 1 ] && [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then SAY_DIM="$(printf '\033')[38;2;113;113;122m" SAY_RESET="$(printf '\033')[0m" fi say() { printf '%s%s%s\n' "$SAY_DIM" "$*" "$SAY_RESET" >&3; } # The program text is awk, not shell: its "$PRIME_AGENT_UI_DIR" expands later. # shellcheck disable=SC2016 banner_awk='# BANNER_AWK_BEGIN # The installer banner: the TUI first-run splash (crates/pa-tui/src/ # onboarding.rs mark_rows + draw_field), ported cell for cell so the same # frame at the same width draws the same canvas; the parity test in that # file runs this program (-v parity=1) against the Rust renderer. function remeu(a, m) { a = a % m; if (a < 0) a += m; return a } function absv(a) { return a < 0 ? -a : a } function put(x, y, ch, tone, pri, k) { if (y < 0 || y >= MH || x < 0 || x >= W) return k = y * W + x if (CP[k] > pri) return CC[k] = ch; CT[k] = tone; CP[k] = pri } function field(frame, x, y, k, hash, cx, cy, contour, hy, si, seg, t, base, wave, ty, i, n, parts, eq) { for (k = 0; k < W * MH; k++) { CC[k] = " "; CT[k] = "D"; CP[k] = 0 } cx = int(W * 36 / 100); cy = int(MH * 54 / 100); hy = int(MH * 58 / 100) for (y = 0; y < MH; y++) for (x = 0; x < W; x++) { hash = (x * 37 + y * 53 + frame * 11 + x * y * 3) % 101 if (hash < 3) put(x, y, "·", "D", 1) contour = absv(x - cx) + absv(y - cy) * 4 + int(x / 6) - frame if (x < int(W * 82 / 100) && remeu(contour, 24) == 12) put(x, y, ((x + y) % 5 == 0) ? "╌" : "·", "B", 2) if (y == hy && x % 2 == 0 && (x + frame) % 13 < 2) put(x, y, "─", ((x + frame) % 3 == 0) ? "A" : "D", 3) if (x >= QX0 && !(x <= QX1 && y <= MH - 1) && x % 4 == 0) { si = int(x / 4); seg = (y + si * 2 + int(frame / 2)) % 6 if (y > 0 && y < MH - 1 && seg < 2) put(x, y, ((si + y) % 4 == 0) ? "┃" : "▎", "L", 4) } } for (t = 0; t < 3; t++) { base = (t == 0) ? int(MH * 30 / 100) : (t == 1) ? int(MH * 49 / 100) : int(MH * 72 / 100) for (x = 0; x < W; x++) { wave = (x * 2 + frame + t * 7) % 16 if (wave > 7) wave = 15 - wave ty = base + int((wave - 3) / 2) if (ty < 0) ty = 0 if ((x + frame + t * 13) % 41 == 0) put(x, ty, "◆", "W", 6) else if ((x + frame) % 12 == 0) put(x, ty, "•", "A", 6) else put(x, ty, "·", "A", 3) } } for (y = 0; y < MH; y++) { n = split(LOGO[y], parts, "|") for (i = 1; i <= n; i++) { eq = index(parts[i], "=") put(LI + substr(parts[i], 1, eq - 1), y, substr(parts[i], eq + 1), "T", 8) } } } function mark_row(y, x, k, s, cur) { s = ""; cur = "" for (x = 0; x < W; x++) { k = y * W + x if (CT[k] != cur) { cur = CT[k]; s = s TONE[cur] } s = s CC[k] } return s RESET } function mb(b) { return sprintf("%.1f MB", b / 1000000) } # Move the cursor to region row i (row 0 is the line the renderer started # on); rows past the bottom are created with newlines. function go(i) { if (i >= existing) { go(existing - 1) while (existing <= i) { printf "\n"; existing++; cur = existing - 1 } return } if (i < cur) printf "%s[%dA", ESC, cur - i else if (i > cur) printf "%s[%dB", ESC, i - cur cur = i } function compose( n, line, label, detail, ppath, total, got, cmd, filled, bar, c) { n = 0 if (banner == 1) { field(frame) R[n++] = "" for (y = 0; y < MH; y++) R[n++] = mark_row(y) R[n++] = "" } line = "" if ((getline line < brandf) < 0) line = "" close(brandf) R[n++] = " " TX "Installing " BOLD "PRIME" RESET TX ITAL " Agent" RESET (line == "" ? "" : " " DIM line RESET) R[n++] = "" while ((getline line < logf) > 0) R[n++] = line close(logf) label = ""; detail = ""; ppath = ""; total = "" if ((getline label < activef) > 0) { getline detail < activef; getline ppath < activef; getline total < activef } close(activef) if (label != "") { if (ppath != "") { # The path never enters the command text: the shell expands the # environment variable itself (a TMPDIR with shell syntax stays data). got = 0; cmd = "{ wc -c < \"$PRIME_AGENT_UI_DIR/ui-progress\"; } 2>/dev/null" cmd | getline got; close(cmd); got += 0 if (total + 0 > 0) { filled = int(got * 20 / total); if (filled > 20) filled = 20 bar = "" for (c = 0; c < 20; c++) bar = bar (c < filled ? "█" : "░") detail = AC bar RESET DIM " " mb(got) " / " mb(total) (detail == "" ? "" : " · " detail) } else detail = mb(got) (detail == "" ? "" : " · " detail) } R[n++] = " " AC SPIN[frame % 10 + 1] RESET " " label (detail == "" ? "" : " " DIM detail RESET) } return n } function draw(n, i) { for (i = 0; i < n; i++) { if ((i in PREV) && PREV[i] == R[i]) continue PREV[i] = R[i] # A row scrolled off the top can no longer be reached: leave it. if (i < existing - TH) continue go(i); printf "\r%s[2K%s", ESC, R[i] } for (i = n; i < pn; i++) { if (i >= existing - TH) { go(i); printf "\r%s[2K", ESC }; delete PREV[i] } pn = n fflush() } BEGIN { ESC = "\033" MH = 7; LI = 5; QX0 = 5; QX1 = 26 LOGO[0] = "17=▗|18=▄|19=▄|20=█|21=▀" LOGO[1] = "3=█|4=█|5=█|6=▄|14=▗|15=▄|16=█|17=█|18=█|19=▀" LOGO[2] = "2=▗|3=█|4=▛|5=▐|6=█|7=▙|11=▗|12=▄|13=█|14=▀|15=▗|16=█|17=▀" LOGO[3] = "1=▗|2=█|3=▛|5=▟|6=█|7=█|8=▙|9=▄|10=█|11=█|12=▛|14=▟|15=▛" LOGO[4] = "1=▗|2=▟|3=▌|5=▐|6=█|7=█|8=█|9=▛|10=▘|11=▗|12=▄|13=█|14=▖" LOGO[5] = "0=▟|1=█|2=█|3=█|4=▄|7=▄|8=▄|9=▟|10=█|11=█|12=█|13=▀" LOGO[6] = "0=▜|1=█|2=▛|3=▀|4=▘|7=▜|8=█|9=▛|10=▀|11=▘" if (color == 1) { RESET = ESC "[0m"; BOLD = ESC "[1m"; ITAL = ESC "[3m"; TX = ESC "[39m" DIM = ESC "[38;2;113;113;122m"; AC = ESC "[38;2;124;111;175m" TONE["T"] = ESC "[39m"; TONE["D"] = DIM; TONE["B"] = ESC "[38;2;82;82;91m" TONE["A"] = AC; TONE["L"] = ESC "[38;2;56;189;248m"; TONE["W"] = ESC "[38;2;245;158;11m" } W = width + 0 if (parity == 1) { field(frame + 0) for (y = 0; y < MH; y++) { chars = ""; tones = "" for (x = 0; x < W; x++) { chars = chars CC[y * W + x]; tones = tones CT[y * W + x] } print chars "\t" tones } exit } split("⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏", SPIN, " ") uidir = ENVIRON["PRIME_AGENT_UI_DIR"] activef = uidir "/ui-active"; logf = uidir "/ui-log"; brandf = uidir "/ui-brand"; stopf = uidir "/ui-stop" TH = height + 0; if (TH < 5) TH = 24 frame = 0; existing = 1; cur = 0; pn = 0 printf "%s[?25l%s[?7l", ESC, ESC while (1) { stopping = ((getline tmp < stopf) >= 0); close(stopf) n = compose() draw(n) if (stopping) break if (system("kill -0 " ppid " 2>/dev/null && { sleep 0.12 2>/dev/null || sleep 1; }") != 0) break frame++ } go(n - 1) printf "\n%s[?7h%s[?25h", ESC, ESC fflush() } # BANNER_AWK_END ' dl="$(mktemp -d "${TMPDIR:-/tmp}/prime-agent-download.XXXXXX")" # The renderer's inputs: the active step (label, detail, the file being # downloaded, its size), the finished lines, the title's version text, and # the stop flag. Warnings wait in ui_notes and print in full once the # renderer has stopped (a frozen frame never clips them). ui_active="${dl}/ui-active" ui_log="${dl}/ui-log" ui_brand="${dl}/ui-brand" ui_stopf="${dl}/ui-stop" ui_notes="${dl}/ui-notes" : > "$ui_log" : > "$ui_brand" : > "$ui_notes" ui_pid="" ui_step="" ui_progress_path="" ui_progress_total="" ui_size() { stty size < /dev/tty 2>/dev/null || true } if [ "$UI_ANIM" = 1 ]; then ui_dims="$(ui_size)" ui_rows="${ui_dims%% *}" ui_cols="${ui_dims##* }" case "$ui_cols" in ""|0|*[!0-9]*) ui_cols="${COLUMNS:-80}" ;; esac case "$ui_rows" in ""|0|*[!0-9]*) ui_rows="${LINES:-24}" ;; esac case "$ui_cols" in ""|*[!0-9]*) ui_cols=80 ;; esac # The mark needs its indent + width (27 columns); the canvas spans the # terminal (one column short of the edge, so a full row never wraps), # capped at 120 columns. ui_width=$((ui_cols - 1)) [ "$ui_width" -le 120 ] || ui_width=120 ui_banner=0 if [ "$UI_COLOR" = 1 ] && [ "$ui_width" -ge 40 ]; then ui_banner=1; fi # The paths ride the environment, never -v (which rewrites backslashes) # and never a command string. PRIME_AGENT_UI_DIR="$dl" awk -v banner="$ui_banner" -v color="$UI_COLOR" \ -v width="$ui_width" -v height="$ui_rows" -v ppid="$$" "$banner_awk" >&2 & ui_pid=$! fi # ui_stop: freeze the renderer on its last frame, then print the waiting # warnings in full. ui_stop() { if [ -n "$ui_pid" ]; then rm -f "$ui_active" : > "$ui_stopf" wait "$ui_pid" 2>/dev/null || true ui_pid="" if [ -s "$ui_notes" ]; then printf '\n' >&2 cat "$ui_notes" >&2 : > "$ui_notes" fi fi } # ui_set_active