#!/usr/bin/env bash
# token-tracker collector — runs on each device, syncs AI-coding usage.
#
# Reads this machine's local usage via `ccusage` (multi-tool), builds per-tool
# rows, and POSTs them to the token-tracker ingestion endpoint. Idempotent:
# the server UPSERTs on (device, tool, date, model), so re-running is safe.
# Installed + scheduled by install.sh.
#
# SYNC WINDOW. The collector runs often (every 5 min by default), so it does NOT
# re-send all of history every time — a day from three months ago cannot change.
# It keeps a watermark in state.env and sends:
#
#   no state yet        -> full history   (first run)
#   last full > 7d ago  -> full history   (weekly backfill; picks up ccusage's
#                                          retroactive cost corrections)
#   otherwise           -> last N days, where N stretches to cover any gap since
#                          the last SUCCESSFUL sync, so an outage self-heals
#
# A partial payload is safe: ingest_usage is a pure per-row UPSERT that never
# deletes, so days outside the window are left untouched. The watermark advances
# ONLY on HTTP 200 — a failed sync re-covers its window on the next run.
#
# install.sh also sources this file to reuse tt_apply_schedule; the main body
# runs only when the file is executed directly.
set -euo pipefail

# This script's own version, bumped by hand when it changes. It rides along on
# every ingest so the server can tell what each device is actually running, and
# so a rollout can be watched rather than hoped about. Keep in sync with
# src/lib/collector-release.ts — a test pins the two together.
TT_COLLECTOR_VERSION="2026.09.23"

CONFIG="${TT_CONFIG:-$HOME/.token-tracker/config.env}"
[ -f "$CONFIG" ] && . "$CONFIG"

DIR="$(dirname "$CONFIG")"
STATE="$DIR/state.env"
[ -f "$STATE" ] && . "$STATE"

# "ccusage-subcommand:tool-id" pairs. ccusage auto-detects which tools have data;
# unsupported/empty ones are skipped. Verify subcommand names against your ccusage.
#
# CLAUDE-ONLY FOR NOW: we ship Claude Code only until each other tool's counting is
# verified against ground truth. ccusage's Codex adapter over-counts (duplicate
# cumulative token_count snapshots — #876/#884, regressed in the Rust rewrite), and
# Gemini/Copilot are unverified. Re-enable a tool here once it's proven accurate:
#   claude:claude-code codex:codex gemini:gemini-cli copilot:copilot-cli
TT_TOOLS="${TT_TOOLS:-claude:claude-code}"

# Pinned, not @latest: at a 5-minute schedule "latest" would re-resolve against the
# npm registry 288x/day. A pinned spec is served from npx's cache — fast, offline-
# safe, and immune to a bad upstream release. Bump deliberately.
#
# But DO bump it when ccusage ships a reader fix. 20.0.17 silently stopped seeing
# newer Claude Code transcript entries: on one device it reported nothing at all
# for two days, and understated the day before that by 3.4x (it dropped every
# fable-5-1 row and undercounted opus-5). ccusage exits 0 and prints a valid,
# short report in that state, so the collector cannot tell it apart from an idle
# machine. Fixed in 20.0.22 (verified on that device, 2026-09-18).
#
# A device that already has TT_CCUSAGE_SPEC in its config.env keeps the version it
# was installed with; this default only reaches NEW installs.
TT_CCUSAGE_SPEC="${TT_CCUSAGE_SPEC:-ccusage@20.0.22}"

# Set by the DEV installer, and available to anyone who wants off the train:
# this collector will never replace itself while it is set.
TT_NO_UPDATE="${TT_NO_UPDATE:-}"

# Consecutive runs a freshly installed version gets to complete ONE successful
# sync before it is assumed broken and the previous copy is restored. Three runs
# is ~45 minutes at the default interval — long enough to ride out a flaky
# network, short enough that a genuinely broken version does not cost a day.
TT_TRIAL_MAX="${TT_TRIAL_MAX:-3}"

# How ccusage is invoked. Override to skip npx entirely if ccusage is installed
# globally (TT_CCUSAGE_CMD=ccusage); tests point it at a stub. Deliberately unquoted
# at the call site so it word-splits into a command + args.
TT_CCUSAGE_CMD="${TT_CCUSAGE_CMD:-npx -y $TT_CCUSAGE_SPEC}"

# Current schedule in seconds; reconciled against the server on each successful
# ingest (see "server-driven interval" below).
TT_INTERVAL="${TT_INTERVAL:-300}"

# Set by the DEV installer. Dev machines are deliberately unscheduled, so they must
# never self-schedule off a server response.
TT_NO_SCHEDULE="${TT_NO_SCHEDULE:-}"

# 2 days, not 1: absorbs the poll interval plus timezone/midnight boundaries, so a
# run that straddles midnight still re-sends the day it just left.
TT_RECENT_DAYS="${TT_RECENT_DAYS:-2}"
TT_FULL_EVERY_DAYS="${TT_FULL_EVERY_DAYS:-7}"

# Clamp whatever the server asks for: one typo'd env var must not be able to brick a
# fleet, and a collector that has stopped running can never learn the corrected value.
INTERVAL_MIN=300
INTERVAL_MAX=86400

# Last line of a captured stderr file, bounded and stripped of anything that
# would break the JSON payload. Errors are for a human to read in the fleet view,
# not a log to ship wholesale.
tt_err_tail() {
  [ -f "$1" ] || return 0
  tail -c 400 "$1" | tr -d '\000' | tr '\n' ' ' | cut -c1-300
}

# --- date helpers: macOS ships BSD date (-v), Linux ships GNU date (-d) ---
days_ago() { date -v-"$1"d +%Y-%m-%d 2>/dev/null || date -d "$1 days ago" +%Y-%m-%d; }
epoch_of() { date -j -f %Y-%m-%d "$1" +%s 2>/dev/null || date -d "$1" +%s; }

# Whole days between $1 (YYYY-MM-DD) and now. A missing/unparseable stamp reports a
# huge gap, which callers read as "stale" — failing toward MORE data, never less.
days_since() {
  local e
  if ! e="$(epoch_of "$1" 2>/dev/null)"; then echo 99999; return; fi
  echo $(( ( $(date +%s) - e ) / 86400 ))
}

# --- schedule ---------------------------------------------------------------
# The single definition of "what our schedule looks like", used both by install.sh
# (first install) and by the collector itself (server-driven interval changes), so
# the two can't drift.
#
#   tt_apply_schedule <interval-seconds> <config-path>
tt_apply_schedule() {
  local interval="$1"
  local config="$2"
  local dir collector
  dir="$(dirname "$config")"
  collector="$dir/collect.sh"

  if [ "$(uname -s)" = "Darwin" ]; then
    local agents="$HOME/Library/LaunchAgents"
    local plist="$agents/co.tokentracker.collector.plist"
    mkdir -p "$agents"
    cat > "$plist" <<EOF
<?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>co.tokentracker.collector</string>
  <key>ProgramArguments</key><array><string>/bin/bash</string><string>${collector}</string></array>
  <key>EnvironmentVariables</key><dict><key>TT_CONFIG</key><string>${config}</string></dict>
  <key>StartInterval</key><integer>${interval}</integer>
  <key>RunAtLoad</key><true/>
  <key>AbandonProcessGroup</key><true/>
  <key>StandardOutPath</key><string>${dir}/collector.log</string>
  <key>StandardErrorPath</key><string>${dir}/collector.log</string>
</dict></plist>
EOF
    # WATCHDOG — a second, deliberately dumb agent whose whole job is: if the
    # collector job has fallen out of launchd, load it back. It exists because
    # the 2026-08-24 outage proved the collector can die in ways that leave it
    # unloaded until the next login (see the incident report); with the
    # watchdog, the worst case for ANY future scheduling bug is "dark for an
    # hour", never "dark until reboot". It is written on every apply (so it
    # tracks the collector plist's path) but loaded only once — its own
    # content never changes in a way that needs a reload, and never touching
    # a loaded job is the safest thing a watchdog can do.
    local wplist="$agents/co.tokentracker.watchdog.plist"
    cat > "$wplist" <<EOF
<?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>co.tokentracker.watchdog</string>
  <key>ProgramArguments</key><array><string>/bin/bash</string><string>-c</string><string>launchctl list co.tokentracker.collector >/dev/null 2>&amp;1 || launchctl load '${plist}'</string></array>
  <key>StartInterval</key><integer>3600</integer>
  <key>RunAtLoad</key><true/>
</dict></plist>
EOF
    launchctl list co.tokentracker.watchdog >/dev/null 2>&1 || launchctl load "$wplist"
    # Reload DETACHED, never inline. On an interval change this function runs
    # INSIDE the very launchd job it is reloading, and `launchctl unload` kills
    # that job's processes — including this script — so an inline unload/load
    # pair dies between its two lines and leaves the job unloaded until the
    # next login. (This took the whole fleet dark once; RunAtLoad on re-login
    # is what brought it back.) A nohup'd child does the swap instead, and
    # AbandonProcessGroup above stops launchd from sweeping that child away
    # with the rest of the job's process group. The 1s delay lets the caller
    # finish its logging and exit cleanly first.
    nohup /bin/bash -c "sleep 1; launchctl unload '$plist' 2>/dev/null; launchctl load '$plist'" >/dev/null 2>&1 &
  else
    # cron has minute granularity and no "every N seconds", so map the interval onto
    # the closest expression it can actually represent.
    local mins=$(( interval / 60 ))
    local expr
    if [ "$mins" -lt 60 ]; then
      expr="*/${mins} * * * *"
    elif [ $(( mins % 60 )) -eq 0 ] && [ $(( mins / 60 )) -lt 24 ]; then
      expr="0 */$(( mins / 60 )) * * *"
    else
      expr="@daily"
    fi
    local line="$expr TT_CONFIG=${config} /bin/bash ${collector} >> ${dir}/collector.log 2>&1"
    local boot="@reboot TT_CONFIG=${config} /bin/bash ${collector} >> ${dir}/collector.log 2>&1"
    # Capture the existing crontab defensively. On a machine with NO crontab yet
    # (a fresh box — the common case) `crontab -l` exits non-zero, and so does `grep`
    # when it filters everything out. Inline in a pipeline under `set -e`/`pipefail`
    # that aborts before our new lines are ever emitted, and we'd hand `crontab -` an
    # empty stdin — silently installing no schedule at all.
    local existing
    existing="$(crontab -l 2>/dev/null | grep -v "$collector" || true)"
    printf '%s\n%s\n%s\n' "$existing" "$line" "$boot" | grep -v '^[[:space:]]*$' | crontab -
  fi
}

# The whole of state.env, written at once.
#
# It is written wholesale rather than patched because every field in it is
# derived from variables this script already holds — and a partial write is how
# you end up with a watermark that disagrees with the update trial.
tt_write_state() {
  ( umask 077
    printf 'TT_LAST_SUCCESS_DATE=%s\nTT_LAST_FULL_DATE=%s\nTT_TRIAL_VERSION=%s\nTT_TRIAL_RUNS=%s\n' \
      "${TT_LAST_SUCCESS_DATE:-}" "${TT_LAST_FULL_DATE:-}" \
      "${TT_TRIAL_VERSION:-}" "${TT_TRIAL_RUNS:-0}" > "$STATE" )
}

# sha256 of a file, on either macOS (shasum) or Linux (sha256sum).
tt_sha256() {
  if command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$1" | awk '{print $1}'
  else
    sha256sum "$1" | awk '{print $1}'
  fi
}

# Rewrite one KEY=value in config.env in place, preserving the file's 0600 mode.
tt_set_config() {
  local key="$1" val="$2" tmp
  tmp="$(mktemp)"
  if grep -q "^${key}=" "$CONFIG" 2>/dev/null; then
    sed "s|^${key}=.*|${key}=${val}|" "$CONFIG" > "$tmp"
  else
    { cat "$CONFIG" 2>/dev/null || true; echo "${key}=${val}"; } > "$tmp"
  fi
  cat "$tmp" > "$CONFIG"   # truncate in place rather than mv, which would drop the mode
  rm -f "$tmp"
}

# Turn one ccusage `daily --json` payload into our flat rows for a given tool.
# ccusage emits two per-model shapes depending on the agent:
#   - Claude:  daily[].modelBreakdowns[] — an ARRAY, each entry has `modelName`
#              and its own per-model `cost`.
#   - Everyone else (codex, gemini, copilot, …): daily[].models — an OBJECT keyed
#              by model name, with cost only at the day level (`costUSD`) and a
#              `reasoningOutputTokens` field. Normalize both to one list so no
#              tool is silently dropped. For the object shape, split the day's
#              cost across models by token share, and fold reasoning into output.
#
# Kept top-level and single-quoted: collect.test.ts extracts this exact filter by
# regex and runs it through jq, so the test can't drift from what ships.
JQ_ROWS='[ .daily[]? as $d
  | ( $d.modelBreakdowns // ( ($d.models // {}) | to_entries | map(.value + {modelName: .key}) ) ) as $mbs
  | $mbs[]? as $m
  | {
      tool: $tool, date: $d.date, model: ($m.modelName // "unknown"),
      input_tokens: ($m.inputTokens // 0),
      output_tokens: (($m.outputTokens // 0) + ($m.reasoningOutputTokens // 0)),
      cache_creation_tokens: ($m.cacheCreationTokens // 0),
      cache_read_tokens: ($m.cacheReadTokens // 0),
      cost_usd: ( $m.cost // ( ($d.costUSD // 0) * ( ($m.totalTokens // 0) / ( ($d.totalTokens // 1) | if . == 0 then 1 else . end ) ) ) ),
      accuracy: "exact"
    } ]'

# Replace this script with the version the server says this device should run.
#
# THE SERVER CANNOT PUSH. Every device calls in, so the desired version rides
# back on the ingest response and the device fetches it itself — the same shape
# as the interval reconciliation below it.
#
# What the server is allowed to say is deliberately tiny: a version string and a
# hash. THE DOWNLOAD URL IS NEVER TAKEN FROM THE RESPONSE — it is derived from
# the ingest URL already in this device's config. A server that could name the
# URL could run anything it liked on every laptop in the fleet, and that is too
# much authority to hand a JSON field.
#
# Nothing is swapped until the new file has proved four things: it is the bytes
# the server described, it parses, it claims the version it was supposed to, and
# it actually runs against this machine's config. A collector that cannot run is
# unreachable forever, so the bar for replacing a working one is high.
tt_self_update() {
  local want_version="$1" want_sha="$2"
  [ -z "${TT_NO_UPDATE:-}" ] || return 0
  [ -n "$want_version" ] || return 0
  [ "$want_version" != "$TT_COLLECTOR_VERSION" ] || return 0
  [ -n "$want_sha" ] || return 0

  local origin="${TT_INGEST_URL%/api/ingest}"
  local new="$DIR/.collect.sh.new"
  local live="$DIR/collect.sh"

  if ! curl -fsSL "$origin/collect.sh" -o "$new" 2>/dev/null; then
    echo "token-tracker: update $want_version — download failed, staying on $TT_COLLECTOR_VERSION" >&2
    rm -f "$new"; return 0
  fi

  local got
  got="$(tt_sha256 "$new" 2>/dev/null || true)"
  if [ "$got" != "$want_sha" ]; then
    echo "token-tracker: update $want_version — checksum mismatch, discarded" >&2
    rm -f "$new"; return 0
  fi

  if ! bash -n "$new" 2>/dev/null; then
    echo "token-tracker: update $want_version — does not parse, discarded" >&2
    rm -f "$new"; return 0
  fi

  local reported
  reported="$(TT_PRINT_VERSION=1 bash "$new" 2>/dev/null || true)"
  if [ "$reported" != "$want_version" ]; then
    echo "token-tracker: update $want_version — file reports '$reported', discarded" >&2
    rm -f "$new"; return 0
  fi

  if ! TT_PRINT_PLAN=1 TT_CONFIG="$CONFIG" bash "$new" >/dev/null 2>&1; then
    echo "token-tracker: update $want_version — will not run here, discarded" >&2
    rm -f "$new"; return 0
  fi

  # Keep the outgoing copy: the device is the only thing that can undo this.
  cp "$live" "$DIR/collect.sh.prev" 2>/dev/null || true
  # mv, never write-in-place: bash reads a running script lazily from disk, so
  # overwriting this very file mid-run would make it execute garbage. Same
  # directory, so the rename is atomic.
  mv "$new" "$live"
  chmod +x "$live" 2>/dev/null || true

  # On trial until it completes a sync of its own (see tt_main).
  TT_TRIAL_VERSION="$want_version"
  TT_TRIAL_RUNS=0
  tt_write_state

  echo "token-tracker: updated $TT_COLLECTOR_VERSION -> $want_version (on trial)."
}

tt_main() {
  # Testable seam, and the way a freshly downloaded copy proves what it is before
  # anything swaps it into place.
  if [ -n "${TT_PRINT_VERSION:-}" ]; then
    echo "$TT_COLLECTOR_VERSION"
    return 0
  fi

  # --- is a freshly installed version on trial? -----------------------------
  #
  # The server cannot rescue a device that has stopped calling in, so recovery
  # lives here. The counter is incremented AND WRITTEN before anything that can
  # fail, so a version that dies mid-run still burns a life.
  #
  # Known gap, accepted: a version broken enough to die before this point never
  # rolls back. That is what the pre-swap dry run in tt_self_update exists to
  # prevent, and the watchdog still reloads a fallen launchd job hourly.
  if [ -n "${TT_TRIAL_VERSION:-}" ]; then
    if [ "$TT_TRIAL_VERSION" != "$TT_COLLECTOR_VERSION" ]; then
      # Something else changed the script under us — the trial is meaningless.
      TT_TRIAL_VERSION=""; TT_TRIAL_RUNS=0
      tt_write_state
    else
      TT_TRIAL_RUNS=$(( ${TT_TRIAL_RUNS:-0} + 1 ))
      tt_write_state
      if [ "$TT_TRIAL_RUNS" -ge "$TT_TRIAL_MAX" ] && [ -f "$DIR/collect.sh.prev" ]; then
        echo "token-tracker: $TT_COLLECTOR_VERSION failed $TT_TRIAL_RUNS runs — rolling back." >&2
        cp "$DIR/collect.sh.prev" "$DIR/collect.sh"
        chmod +x "$DIR/collect.sh" 2>/dev/null || true
        TT_TRIAL_VERSION=""; TT_TRIAL_RUNS=0
        tt_write_state
        return 0
      fi
    fi
  fi

  # --- decide the window ---
  local mode="recent" since="" gap lookback
  if [ -z "${TT_LAST_SUCCESS_DATE:-}" ]; then
    mode="full"                                     # first run ever
  elif [ "$(days_since "${TT_LAST_SUCCESS_DATE}")" -ge "$TT_FULL_EVERY_DAYS" ]; then
    # Down for a week+, or the stamp is corrupt (days_since reports a huge gap for
    # anything unparseable). Either way we no longer know what we're missing, so
    # re-send everything rather than guess at a window. This also bounds `lookback`
    # below to at most TT_FULL_EVERY_DAYS.
    mode="full"
  elif [ -z "${TT_LAST_FULL_DATE:-}" ] \
    || [ "$(days_since "${TT_LAST_FULL_DATE}")" -ge "$TT_FULL_EVERY_DAYS" ]; then
    mode="full"                                     # weekly backfill
  else
    # Cover everything since the last SUCCESSFUL sync, but never less than
    # TT_RECENT_DAYS. Normal run: gap 0 -> 2 days. Collector down 5 days: gap 5 ->
    # 6 days, so the missed days are recovered instead of being lost forever.
    gap="$(days_since "${TT_LAST_SUCCESS_DATE}")"
    lookback="$TT_RECENT_DAYS"
    if [ "$(( gap + 1 ))" -gt "$lookback" ]; then lookback=$(( gap + 1 )); fi
    since="$(days_ago "$lookback")"
  fi

  # Testable seam: report the decision and stop, touching neither npm nor the network.
  if [ -n "${TT_PRINT_PLAN:-}" ]; then
    echo "mode=${mode} since=${since:--}"
    return 0
  fi

  : "${TT_TOKEN:?TT_TOKEN not set (run install.sh)}"
  : "${TT_INGEST_URL:?TT_INGEST_URL not set (run install.sh)}"

  # Make node/npx reachable under launchd/cron's minimal env.
  export PATH="$HOME/.volta/bin:$HOME/.nvm/versions/node/$(ls -1 "$HOME/.nvm/versions/node" 2>/dev/null | tail -n1)/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"

  local tmp
  tmp="$(mktemp -d)"
  trap 'rm -rf "$tmp"' RETURN

  # How the reader fared, reported to the server below. Discarding this is what
  # made a blind device look like an idle one for three days: ccusage can exit 0
  # with a short, valid report, and it can fail outright — and until now both
  # looked exactly like "this person wasn't working".
  local pair sub tool json rc
  local reader_ok="true" reader_error=""
  for pair in $TT_TOOLS; do
    sub="${pair%%:*}"
    tool="${pair##*:}"
    # Bound the window only in `recent` mode; `full` omits --since entirely.
    local args=(daily --json --offline)
    if [ -n "$since" ]; then args+=(--since "$since"); fi
    # shellcheck disable=SC2086  # TT_CCUSAGE_CMD must word-split into command + args
    if json="$($TT_CCUSAGE_CMD "$sub" "${args[@]}" 2>"$tmp/$tool.err")"; then
      if ! printf '%s' "$json" | jq --arg tool "$tool" "$JQ_ROWS" \
           > "$tmp/$tool.json" 2>"$tmp/$tool.jqerr"; then
        rm -f "$tmp/$tool.json"
        reader_ok="false"
        reader_error="$sub: unreadable output — $(tt_err_tail "$tmp/$tool.jqerr")"
      fi
    else
      rc=$?
      # A tool that simply isn't installed on this machine is not an error worth
      # reporting; every other non-zero exit is.
      if [ "$rc" != 127 ]; then
        reader_ok="false"
        reader_error="$sub: exited $rc — $(tt_err_tail "$tmp/$tool.err")"
      fi
    fi
  done

  # Combine all per-tool arrays into a single { "rows": [...] } payload.
  local payload="$tmp/payload.json"
  if ls "$tmp"/*.json >/dev/null 2>&1; then
    jq -s 'add | {rows: .}' "$tmp"/*.json > "$payload"
  else
    # STILL CALL IN. A device with nothing to report has to reach the server
    # anyway, or a broken reader is indistinguishable from a machine that is
    # switched off — and the `agent` block below, which is the only way that
    # breakage can announce itself, would never be sent.
    echo '{"rows":[]}' > "$payload"
  fi

  # What this device is and how its reader did. The server stores it against the
  # device so the fleet can be seen rather than guessed at.
  local count http
  count="$(jq '.rows | length' "$payload")"
  jq --arg version "$TT_COLLECTOR_VERSION" \
     --arg spec "$TT_CCUSAGE_SPEC" \
     --argjson ok "$reader_ok" \
     --arg err "$reader_error" \
     --argjson sent "$count" \
     '. + {agent: {collector_version: $version, ccusage_spec: $spec,
                   reader_ok: $ok, reader_error: $err, rows_sent: $sent}}' \
     "$payload" > "$payload.new" && mv "$payload.new" "$payload"

  if [ "$reader_ok" = "false" ]; then
    echo "token-tracker: reader problem — $reader_error" >&2
  fi
  echo "token-tracker: sending $count rows (${mode}${since:+ since $since}) to $TT_INGEST_URL"

  http="$(curl -s -o "$tmp/resp.json" -w '%{http_code}' \
    -X POST "$TT_INGEST_URL" \
    -H "Authorization: Bearer $TT_TOKEN" \
    -H 'content-type: application/json' \
    --data-binary "@$payload")"

  if [ "$http" != "200" ]; then
    # Watermark deliberately NOT advanced — the next run re-covers this window.
    echo "token-tracker: ingest failed (HTTP $http): $(cat "$tmp/resp.json")" >&2
    return 1
  fi

  echo "token-tracker: ok — $(cat "$tmp/resp.json")"

  # --- advance the watermark (only now that the server actually has the data) ---
  today="$(date +%Y-%m-%d)"
  TT_LAST_SUCCESS_DATE="$today"
  if [ "$mode" = "full" ]; then TT_LAST_FULL_DATE="$today"; fi
  # A sync that reached the server is the proof a version was waiting for.
  TT_TRIAL_VERSION=""
  TT_TRIAL_RUNS=0
  tt_write_state

  # --- server-driven collector version ---
  # Ahead of the interval block below, which returns early on the common path.
  tt_self_update \
    "$(jq -r '.collector.version // empty' "$tmp/resp.json" 2>/dev/null || true)" \
    "$(jq -r '.collector.sha256  // empty' "$tmp/resp.json" 2>/dev/null || true)"

  # --- server-driven interval ---
  # The server can't push to a laptop, but we call it every run, so the desired
  # interval rides along on the ingest response. Flip one env var server-side and
  # every device converges on its next sync, with nobody touching a machine.
  local want
  want="$(jq -r '.interval_seconds // empty' "$tmp/resp.json" 2>/dev/null || true)"
  if ! [[ "$want" =~ ^[0-9]+$ ]]; then
    return 0                                        # absent or non-numeric: ignore
  fi
  if [ "$want" -lt "$INTERVAL_MIN" ]; then want="$INTERVAL_MIN"; fi
  if [ "$want" -gt "$INTERVAL_MAX" ]; then want="$INTERVAL_MAX"; fi
  if [ "$want" = "$TT_INTERVAL" ]; then
    return 0
  fi

  echo "token-tracker: interval ${TT_INTERVAL}s -> ${want}s (server)."
  tt_set_config TT_INTERVAL "$want"
  # Dev machines are deliberately unscheduled: record the new value, but never touch
  # launchd/cron, or a dev install would start scheduling itself behind your back.
  if [ -z "$TT_NO_SCHEDULE" ]; then
    tt_apply_schedule "$want" "$CONFIG"
  fi
}

# Run only when executed. install.sh sources this file to reuse tt_apply_schedule.
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
  tt_main "$@"
fi
