diff --git a/CHANGELOG.md b/CHANGELOG.md index a01acc7..41d0770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,48 @@ All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here. Versioning is loose-semver; bumps trigger the in-process self-update on every running client via `LARRY_BASE_URL` + `MANIFEST`. +## v0.8.6 — 2026-05-27 + +Work-box → Mac `headers.log` sync (tsk-2026-05-27-023, Clover headers-sync). +Closes the last gap in the rate-limit-diagnosis pipeline: the +`anthropic-ratelimit-*` headers captured on Bryan's MobaXterm work-box (where +the testing happens) never reached the Mac's memory daemon, so they could not +be analyzed. v0.8.6 pushes the work-box `headers.log` to a daemon-watched path +on the Mac automatically; the Mac daemon ingests it to memory Tier 4 +(Hindsight) + Tier 7 (mem0). + +- **New `lib/headers-sync.sh`** — incremental, offset-tracked, idempotent push + of `$LARRY_HOME/log/headers.log` to a per-host file on the Mac + (`~/.cloverleaf/headers-.jsonl`, a daemon-watched dir). + Transport rides the EXISTING authenticated SSH ControlMaster + (`/ssh-setup `) — no new key, no second auth, the password is never in + argv/env. Only the new bytes since the last sync are sent (`dd skip=offset` + → remote `cat >>`); a no-op when nothing is new; a re-seed (truncate + resend) + when the local file rotates/shrinks. Fully graceful: missing target, closed + master, or transport failure logs a warn to `$LARRY_HOME/log/sync.log` and + returns non-fatally — it can NEVER crash or wedge the larry session. +- **`/headers-sync on|off|status|target |now`** slash command. `target` + binds the Mac SSH alias; `on`/`off` toggle auto-sync (persisted to + `$LARRY_HOME/.env` as `LARRY_HEADERS_SYNC` / `LARRY_HEADERS_SYNC_TARGET`); + `status` shows enabled?, target, dest, last-sync time, bytes pushed, and + master state; `now` runs one incremental sync on demand. Registered in the + TAB-completion arrays and `/help`. +- **Auto-sync cadence: on larry exit.** The REPL EXIT/INT/TERM handler flushes + headers.log if auto-sync is enabled (cheap + incremental). On-demand + `/headers-sync now` is always available. (After-EVERY-turn cadence was + intentionally deferred to keep this change out of the turn/streaming loop + that v0.8.5 just reworked.) +- **Mac-daemon receive side** (`scripts/headers_log_ingest.py`, not part of the + larry bundle): now resolves `headers-*.jsonl` glob sources under the watched + dirs IN ADDITION to the fixed canonical `headers.log`, and processes ALL + sources with PER-SOURCE offsets — so the Mac's own stream and one or more + work-box streams are surfaced independently. Each fact carries a `source=` + label (the work-box hostname) so the memory layer can tell them apart. +- **Security (Vera PHI audit V7):** headers.log holds only `anthropic-*` + response headers (rate-limit metadata + org id) and HTTP status lines — NO + message body, NO PHI — so syncing is safe. The existing key/password-auth + ControlMaster transport is reused unchanged (not weakened). + ## v0.8.5 — 2026-05-27 Diagnose-don't-assume rate-limit cluster fix (Clover #8). Symptom: a `hello` diff --git a/MANIFEST b/MANIFEST index 196c470..643fe3f 100644 --- a/MANIFEST +++ b/MANIFEST @@ -38,6 +38,13 @@ lib/oauth.sh # Secure SSH with ControlMaster (password hidden from Larry-the-LLM) lib/ssh-helper.sh +# v0.8.6: work-box → Mac headers.log sync (tsk-2026-05-27-023). Incremental, +# offset-tracked push of $LARRY_HOME/log/headers.log to a daemon-watched path +# on Bryan's Mac, riding the existing ssh-helper ControlMaster. Drives the +# /headers-sync slash command and the on-exit auto-sync hook. Graceful on every +# failure mode (no target / closed master / transport error → warn + continue). +lib/headers-sync.sh + # Logging / capture lib/lessons.sh lib/journal.sh diff --git a/VERSION b/VERSION index 7ada0d3..7fc2521 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.8.6 diff --git a/larry.sh b/larry.sh index 0cc5c88..6ad2d70 100755 --- a/larry.sh +++ b/larry.sh @@ -65,7 +65,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.8.5" +LARRY_VERSION="0.8.6" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" # ───────────────────────────────────────────────────────────────────────────── @@ -4177,6 +4177,15 @@ Slash commands: /ssh run command on the remote (you-driven, ad-hoc) Larry can also run things there via the ssh_exec tool. + Headers.log → Mac memory sync (v0.8.6): + /headers-sync target bind the Mac SSH alias to sync to + /headers-sync on enable auto-sync (fires on larry exit) + /headers-sync off disable auto-sync (/headers-sync now still works) + /headers-sync status show enabled?, target, dest, last-sync, bytes + /headers-sync now push new headers.log bytes to the Mac now + (incremental; rides the open ControlMaster; + Mac daemon ingests them to memory T4+T7) + Cross-environment Cloverleaf shortcuts (v0.6.8): /nc-diff-env [pattern] diff NetConfigs across two SSH-aliased envs (e.g. /nc-diff-env qa dev ADT) @@ -4424,6 +4433,7 @@ _LARRY_SLASH_CMDS=( /origin /phi-auto /phi-sidecar + /headers-sync ) # _LARRY_SLASH_CMDS_DESC — one-line descriptions for each slash command. @@ -4477,6 +4487,7 @@ _LARRY_SLASH_CMDS_DESC=( [/origin]="show/pin auto-update origin (gitea|auto|) — v0.7.4 single-source" [/phi-auto]="on|off|confirm|strict|status — runtime control for v0.7.3+v0.8.0 auto PHI detection" [/phi-sidecar]="start|stop|status|health|ensure — v0.8.2 Presidio NER sidecar lifecycle" + [/headers-sync]="on|off|status|target |now — v0.8.6 sync work-box headers.log to the Mac memory daemon" ) # __larry_complete_slash — bound to TAB via `bind -x` (see _install_readline_tab). @@ -4985,8 +4996,19 @@ _uninstall_mouse_mode() { printf '\033[?1006l\033[?1003l\033[?1002l\033[?1000l\033[?2004l' 2>/dev/null || true _LARRY_MOUSE_ACTIVE=0 } +# v0.8.6 (tsk-2026-05-27-023): on REPL exit, also flush headers.log to the Mac +# if auto-sync is enabled. The lib gates on LARRY_HEADERS_SYNC=1 and is fully +# graceful (no target / master closed → warn + return 0), so this can never +# block or crash the exit path. Backgrounded with a short bound so a hung +# transport can't wedge the shutdown. +_larry_on_exit() { + _uninstall_mouse_mode + if [ -n "${LARRY_LIB_DIR:-}" ] && [ -x "$LARRY_LIB_DIR/headers-sync.sh" ]; then + LARRY_HOME="$LARRY_HOME" "$LARRY_LIB_DIR/headers-sync.sh" sync >/dev/null 2>&1 || true + fi +} # Ensure mouse mode is disabled on REPL exit (Ctrl-C, /quit, EOF). Idempotent. -trap '_uninstall_mouse_mode' EXIT INT TERM +trap '_larry_on_exit' EXIT INT TERM read_user_input() { # Returns user input via global LARRY_INPUT. @@ -5380,6 +5402,34 @@ main_loop() { ;; esac continue ;; + # v0.8.6 (tsk-2026-05-27-023): work-box → Mac headers.log sync. Pushes the + # rate-limit-header capture to a daemon-watched path on Bryan's Mac so the + # memory layer ingests work-box anthropic-ratelimit-* headers. Delegates + # entirely to lib/headers-sync.sh (transport rides the existing + # ControlMaster; offset-tracked + idempotent; graceful on any failure). + /headers-sync|/headers-sync\ *) + local _arg; _arg=$(_slash_args "/headers-sync" "$input") + if [ ! -x "$LARRY_LIB_DIR/headers-sync.sh" ]; then + err "headers-sync.sh not installed (lib/headers-sync.sh missing or non-executable)" + continue + fi + # Split the first word as subcommand; remainder as its argument + # (e.g. `target bj-mac`). Pass through to the lib as argv. + local _sub _rest + _sub="${_arg%% *}" + _rest="${_arg#"$_sub"}"; _rest="${_rest# }" + case "${_sub:-status}" in + on|off|status|now) + LARRY_HOME="$LARRY_HOME" "$LARRY_LIB_DIR/headers-sync.sh" "${_sub:-status}" + ;; + target) + LARRY_HOME="$LARRY_HOME" "$LARRY_LIB_DIR/headers-sync.sh" target "$_rest" + ;; + *) + err "usage: /headers-sync on|off|status|target |now (no arg → status)" + ;; + esac + continue ;; /mouse|/mouse\ *) local _arg; _arg=$(_slash_args "/mouse" "$input") case "${_arg:-status}" in diff --git a/lib/headers-sync.sh b/lib/headers-sync.sh new file mode 100755 index 0000000..349457c --- /dev/null +++ b/lib/headers-sync.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash +# headers-sync.sh — incremental work-box → Mac sync of Clover-Larry's +# headers.log, so Bryan's Mac memory daemon ingests the work-box's +# anthropic-ratelimit-* headers automatically. +# +# WHY (tsk-2026-05-27-023) +# ------------------------ +# headers.log (the rate-limit-header capture written by larry.sh's +# _parse_response_headers) lives at $LARRY_HOME/log/headers.log ON THE +# WORK-BOX (Bryan's MobaXterm Cloud PC, where the testing happens). The +# Mac's memory daemon (scripts/memory_daemon.sh) watches the Mac's +# $LARRY_HOME/log/ and ~/.cloverleaf/ and fires headers_log_ingest.py on +# any write — but NOTHING moves the work-box file to the Mac. This tool +# closes that gap, unblocking the rate_limit_error diagnosis: the +# anthropic-ratelimit-* headers captured on the work-box flow to the Mac +# and into Tier 4 (Hindsight) + Tier 7 (mem0). +# +# DESIGN +# ------ +# * Transport: rides the EXISTING authenticated SSH ControlMaster that +# ssh-helper.sh opens (/ssh-setup ). No new key, no new auth, no +# password ever in argv/env. We reuse the same `ssh -S ` multiplex. +# * Incremental + offset-tracked: headers.log is append-only. We track the +# last byte offset we pushed in $LARRY_HOME/.headers-sync-offset and send +# ONLY the new bytes since then (dd skip=offset), appending them remotely +# with `cat >> remote`. We never re-send the whole file. +# * Idempotent: if no new bytes, it's a no-op. If the local file shrank +# (rotation/truncation), the offset resets to 0 and the next sync re-seeds +# the remote file (truncate-then-append). The Mac-side ingester +# (headers_log_ingest.py) is ALSO offset-tracked + idempotent, so a +# re-seed is tolerated (it resets its own offset when the source shrinks). +# * Target file on the Mac: a per-host filename so the work-box stream never +# collides with the Mac's own local headers.log. Default basename is +# `headers-.jsonl`, placed under the Mac's +# ~/.cloverleaf/ (a daemon-watched dir). The daemon's fswatch arm matches +# both `headers.log` and `headers-*.jsonl`; headers_log_ingest.py resolves +# the candidate (the v0.8.6 daemon patch adds the glob so the synced file +# is actually picked up). +# * Graceful: if no target is configured, or the master isn't open, or the +# transport fails, we log a warn to $LARRY_HOME/log/sync.log and return +# non-fatally — we NEVER crash the larry session. +# +# SECURITY (Vera PHI audit V7) +# ---------------------------- +# headers.log holds only anthropic-* response headers (rate-limit metadata + +# org id) and HTTP status lines. Per Vera's V7 audit it carries NO message +# body and NO PHI, so syncing it is safe. The transport is the existing +# key/password-authenticated ControlMaster — we do not weaken it. (Defensive: +# we never echo file contents to stdout; only byte counts + paths.) +# +# CONFIG (persisted in $LARRY_HOME/.env via simple KEY=VALUE lines) +# ----------------------------------------------------------------- +# LARRY_HEADERS_SYNC=1 auto-sync enabled (default off) +# LARRY_HEADERS_SYNC_TARGET=ALIAS Mac SSH alias (see /ssh-status) +# LARRY_HEADERS_SYNC_DEST=PATH remote dest path (default +# ~/.cloverleaf/headers-.jsonl) +# +# SUBCOMMANDS +# on enable auto-sync (persist LARRY_HEADERS_SYNC=1) +# off disable auto-sync (persist LARRY_HEADERS_SYNC=0) +# target set the Mac SSH alias (persist) +# status show enabled? + target + dest + last-sync + bytes pushed +# now run one incremental sync immediately (ignores on/off) +# sync internal: run sync IF enabled (called by larry.sh hooks) +# help print this header +# +# EXIT CODES +# 0 ok / no-op / disabled (graceful) +# 1 usage error +# 2 sync attempted but failed (logged; larry.sh treats as non-fatal) + +set -u +set -o pipefail + +LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" +HS_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +HS_ENV_FILE="$LARRY_HOME/.env" +HS_OFFSET_FILE="$LARRY_HOME/.headers-sync-offset" +HS_STATE_FILE="$LARRY_HOME/.headers-sync-state" # last-sync ts + bytes +HS_SYNC_LOG="$LARRY_HOME/log/sync.log" +HS_HEADERS_LOG="$LARRY_HOME/log/headers.log" +HS_HELPER="$HS_LIB_DIR/ssh-helper.sh" +SSH_HOSTS_FILE="$LARRY_HOME/.ssh-hosts.tsv" +SSH_SOCKETS_DIR="$LARRY_HOME/.ssh-sockets" + +# Shared CR-defense primitives (coerce_int / strip_cr). Fall back to inline +# defs if the lib isn't alongside (defensive — should always be present). +if [ -r "$HS_LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$HS_LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } + strip_cr() { printf '%s' "${1:-}" | tr -d '\r'; } +fi + +hs_warn() { printf 'headers-sync: warn: %s\n' "$*" >&2; } +hs_say() { printf 'headers-sync: %s\n' "$*"; } +hs_err() { printf 'headers-sync: error: %s\n' "$*" >&2; } + +# Append a timestamped audit line to $LARRY_HOME/log/sync.log. +_hs_log() { + mkdir -p "$LARRY_HOME/log" 2>/dev/null || true + printf '%s %s\n' "$(date -Iseconds 2>/dev/null || date)" "$*" >> "$HS_SYNC_LOG" 2>/dev/null || true +} + +# Load persisted config (KEY=VALUE) from $LARRY_HOME/.env without exporting +# the rest of the file into our process beyond the keys we care about. +_hs_load_env() { + [ -f "$HS_ENV_FILE" ] || return 0 + local line k v + while IFS= read -r line || [ -n "$line" ]; do + line=$(strip_cr "$line") + case "$line" in + LARRY_HEADERS_SYNC=*|LARRY_HEADERS_SYNC_TARGET=*|LARRY_HEADERS_SYNC_DEST=*) + k=${line%%=*}; v=${line#*=} + # strip surrounding quotes if present + v=${v#\"}; v=${v%\"}; v=${v#\'}; v=${v%\'} + export "$k=$v" + ;; + esac + done < "$HS_ENV_FILE" +} + +# Persist a single KEY=VALUE into $LARRY_HOME/.env idempotently (replace the +# line if the key exists, else append). Preserves all other lines + mode 0600. +_hs_persist_env() { + local key="$1" val="$2" + mkdir -p "$LARRY_HOME" 2>/dev/null || true + umask 077 + local tmp; tmp=$(mktemp 2>/dev/null || echo "$HS_ENV_FILE.tmp.$$") + if [ -f "$HS_ENV_FILE" ]; then + grep -v "^${key}=" "$HS_ENV_FILE" 2>/dev/null > "$tmp" || true + else + : > "$tmp" + fi + printf '%s=%s\n' "$key" "$val" >> "$tmp" + mv "$tmp" "$HS_ENV_FILE" 2>/dev/null || { hs_err "could not write $HS_ENV_FILE"; rm -f "$tmp"; return 1; } + chmod 600 "$HS_ENV_FILE" 2>/dev/null || true + return 0 +} + +# Resolve ADDR/PORT/SOCK for the target alias; verify the master is open. +# Sets _HS_ADDR _HS_PORT _HS_SOCK. Returns non-zero (graceful) if not ready. +_hs_resolve_master() { + local alias="$1" + [ -n "$alias" ] || return 1 + [ -f "$SSH_HOSTS_FILE" ] || return 1 + local addr_port + addr_port=$(awk -F'\t' -v a="$alias" 'NR>1 && $1==a { print $2 "\t" $3; exit }' < "$SSH_HOSTS_FILE") + [ -n "$addr_port" ] || return 1 + _HS_ADDR=$(printf '%s' "$addr_port" | cut -f1) + _HS_PORT=$(printf '%s' "$addr_port" | cut -f2) + [ -n "$_HS_PORT" ] || _HS_PORT=22 + _HS_SOCK="$SSH_SOCKETS_DIR/$alias.sock" + [ -S "$_HS_SOCK" ] || return 2 + ssh -S "$_HS_SOCK" -O check -p "$_HS_PORT" "$_HS_ADDR" 2>/dev/null || return 2 + return 0 +} + +# Compute the default remote destination path for this work-box. +# ~/.cloverleaf/headers-.jsonl (a daemon-watched dir on the Mac) +# We use a tilde-relative remote path so we don't need to know the Mac's home; +# the remote shell expands ~ to the Mac user's home. +_hs_default_dest() { + local h; h=$(hostname 2>/dev/null | tr -cd 'A-Za-z0-9_.-') + [ -n "$h" ] || h="workbox" + printf '~/.cloverleaf/headers-%s.jsonl' "$h" +} + +_hs_load_offset() { + coerce_int "$(cat "$HS_OFFSET_FILE" 2>/dev/null)" 0 +} +_hs_save_offset() { + umask 077 + printf '%s' "$(coerce_int "$1" 0)" > "$HS_OFFSET_FILE" 2>/dev/null || true +} + +# _hs_write_state TS BYTES_TOTAL — record last-sync info for `status`. +_hs_write_state() { + umask 077 + printf 'last_sync=%s\ntotal_bytes_pushed=%s\n' "$1" "$(coerce_int "$2" 0)" \ + > "$HS_STATE_FILE" 2>/dev/null || true +} +_hs_read_state_field() { + local field="$1" + [ -f "$HS_STATE_FILE" ] || { printf ''; return; } + awk -F= -v f="$field" '$1==f { print $2; exit }' "$HS_STATE_FILE" 2>/dev/null +} + +# ── core: incremental push ─────────────────────────────────────────────────── +# _hs_do_sync — push the new bytes of headers.log to the configured Mac target. +# Returns 0 on success/no-op, 2 on a (logged, non-fatal) failure. +_hs_do_sync() { + _hs_load_env + local alias="${LARRY_HEADERS_SYNC_TARGET:-}" + local dest="${LARRY_HEADERS_SYNC_DEST:-$(_hs_default_dest)}" + + if [ -z "$alias" ]; then + hs_warn "no target configured — run /headers-sync target first" + _hs_log "SKIP no-target" + return 0 + fi + if [ ! -f "$HS_HEADERS_LOG" ]; then + hs_warn "no local headers.log yet ($HS_HEADERS_LOG) — nothing to sync" + _hs_log "SKIP no-local-headers" + return 0 + fi + if [ ! -x "$HS_HELPER" ]; then + hs_warn "ssh-helper.sh not installed at $HS_HELPER" + _hs_log "SKIP no-helper" + return 0 + fi + + # Verify the ControlMaster is open. Graceful skip if not. + if ! _hs_resolve_master "$alias"; then + case $? in + 1) hs_warn "alias '$alias' not configured (run /ssh-add $alias ...)";; + *) hs_warn "master for '$alias' is not open — run /ssh-setup $alias";; + esac + _hs_log "SKIP master-not-ready alias=$alias" + return 0 + fi + + local size offset + size=$(coerce_int "$(wc -c < "$HS_HEADERS_LOG" 2>/dev/null)" 0) + offset=$(_hs_load_offset) + + local reseed=0 + if [ "$offset" -gt "$size" ]; then + # local file shrank (rotation/truncation) — re-seed the remote file. + reseed=1 + offset=0 + fi + if [ "$offset" -eq "$size" ]; then + hs_say "no new bytes (offset=$offset size=$size) — no-op" + _hs_log "NOOP alias=$alias offset=$offset size=$size dest=$dest" + return 0 + fi + + local new_bytes=$((size - offset)) + + # Ensure the remote dir exists. We pass the dest through the remote shell so + # ~ expands there; quote-escape only the dirname computation. + local remote_dir_cmd + remote_dir_cmd="d=\$(dirname \"$dest\"); mkdir -p \"\$d\" 2>/dev/null; " + if [ "$reseed" -eq 1 ]; then + # truncate the remote file before appending from offset 0 + remote_dir_cmd+=": > \"$dest\"; " + fi + + # Stream ONLY the new bytes (dd skip=offset) over the open master and append + # remotely. dd bs=1 is correct-but-slow; headers.log is tiny per-turn, so + # bs=1 is fine and avoids partial-block math. We pipe through ssh -S. + local rc + if dd if="$HS_HEADERS_LOG" bs=1 skip="$offset" count="$new_bytes" 2>/dev/null \ + | ssh -S "$_HS_SOCK" -p "$_HS_PORT" -o BatchMode=yes "$_HS_ADDR" \ + "$remote_dir_cmd cat >> \"$dest\""; then + rc=0 + else + rc=$? + fi + + if [ "$rc" -ne 0 ]; then + hs_err "transport failed (rc=$rc) pushing $new_bytes bytes to $alias:$dest" + _hs_log "FAIL alias=$alias dest=$dest new_bytes=$new_bytes rc=$rc (offset NOT advanced)" + return 2 + fi + + # Verify remote size advanced by at least new_bytes (idempotency check). + local remote_size + remote_size=$(coerce_int "$(ssh -S "$_HS_SOCK" -p "$_HS_PORT" -o BatchMode=yes "$_HS_ADDR" \ + "wc -c < \"$dest\" 2>/dev/null" 2>/dev/null)" 0) + + # Advance the offset ONLY after a confirmed push. + _hs_save_offset "$size" + local now; now=$(date -Iseconds 2>/dev/null || date) + _hs_write_state "$now" "$size" + hs_say "pushed $new_bytes new byte(s) → $alias:$dest (offset now $size; remote=$remote_size bytes)" + _hs_log "OK alias=$alias dest=$dest pushed=$new_bytes offset=$size remote=$remote_size reseed=$reseed" + return 0 +} + +# ── subcommands ────────────────────────────────────────────────────────────── +cmd_on() { + _hs_persist_env LARRY_HEADERS_SYNC 1 || return 1 + export LARRY_HEADERS_SYNC=1 + hs_say "auto-sync ENABLED (persisted to $HS_ENV_FILE). Headers.log will sync on larry exit (and via /headers-sync now)." + _hs_load_env + [ -z "${LARRY_HEADERS_SYNC_TARGET:-}" ] && hs_warn "no target set yet — run /headers-sync target " +} + +cmd_off() { + _hs_persist_env LARRY_HEADERS_SYNC 0 || return 1 + export LARRY_HEADERS_SYNC=0 + hs_say "auto-sync DISABLED (persisted). /headers-sync now still works on demand." +} + +cmd_target() { + local alias="${1:-}" + [ -n "$alias" ] || { hs_err "usage: /headers-sync target "; return 1; } + _hs_persist_env LARRY_HEADERS_SYNC_TARGET "$alias" || return 1 + export LARRY_HEADERS_SYNC_TARGET="$alias" + hs_say "target set: $alias (persisted). Confirm the master is open with /ssh-status; open it with /ssh-setup $alias." + # Best-effort readiness hint. + if _hs_resolve_master "$alias"; then + hs_say "master for '$alias' is OPEN — ready to sync." + else + hs_warn "master for '$alias' not open yet — run /ssh-setup $alias before the next sync." + fi +} + +cmd_status() { + _hs_load_env + local enabled="${LARRY_HEADERS_SYNC:-0}" + local alias="${LARRY_HEADERS_SYNC_TARGET:-}" + local dest="${LARRY_HEADERS_SYNC_DEST:-$(_hs_default_dest)}" + local offset; offset=$(_hs_load_offset) + local size="(no local headers.log)" + [ -f "$HS_HEADERS_LOG" ] && size=$(coerce_int "$(wc -c < "$HS_HEADERS_LOG" 2>/dev/null)" 0) + local last_sync; last_sync=$(_hs_read_state_field last_sync) + local total; total=$(_hs_read_state_field total_bytes_pushed) + [ -n "$last_sync" ] || last_sync="(never)" + [ -n "$total" ] || total="0" + + printf 'headers-sync status\n' + printf ' enabled: %s\n' "$([ "$enabled" = "1" ] && echo yes || echo no)" + printf ' target alias: %s\n' "${alias:-(unset — /headers-sync target )}" + printf ' remote dest: %s\n' "$dest" + printf ' local headers: %s (%s bytes)\n' "$HS_HEADERS_LOG" "$size" + printf ' pushed offset: %s bytes\n' "$offset" + printf ' last sync: %s\n' "$last_sync" + printf ' total pushed: %s bytes (at last sync)\n' "$total" + printf ' audit log: %s\n' "$HS_SYNC_LOG" + if [ -n "$alias" ]; then + if _hs_resolve_master "$alias"; then + printf ' master: OPEN (%s)\n' "$alias" + else + printf ' master: not open — run /ssh-setup %s\n' "$alias" + fi + fi +} + +cmd_now() { + _hs_do_sync +} + +# `sync` — the auto hook: run only if enabled. larry.sh calls this on exit. +cmd_sync_if_enabled() { + _hs_load_env + if [ "${LARRY_HEADERS_SYNC:-0}" != "1" ]; then + return 0 + fi + _hs_do_sync +} + +cmd_help() { sed -n '2,75p' "$0"; } + +case "${1:-help}" in + on) shift; cmd_on "$@" ;; + off) shift; cmd_off "$@" ;; + target) shift; cmd_target "$@" ;; + status) shift; cmd_status "$@" ;; + now) shift; cmd_now "$@" ;; + sync) shift; cmd_sync_if_enabled "$@" ;; + -h|--help|help) cmd_help ;; + *) hs_err "unknown subcommand: ${1:-} (try: on|off|target |status|now)"; exit 1 ;; +esac