#!/usr/bin/env bash
# autopilot-state.sh  -  where continuous mode keeps its state, and who may read it.
#
# Everything lives under ~/.claude/autopilot/ and NOT in
# multi-agent-preferences.json. That file has `additionalProperties: false` and a
# migration chain, so a block there would push keys into every local user's
# preferences forever - including everyone who never turns this on. Separation
# also gives the off state a definition that cannot be got wrong: the directory
# is absent.
#
#   config.json      the repo selection. Survives autopilot-off on purpose, so
#                    turning the mode back on does not re-ask which repos.
#   queue.json       the current ordered list plus whatever is in flight
#   status.json      what the menu bar renders. Written by the runner, read-only
#                    to everything else
#   attempted.jsonl  append-only: {source,id,taskId,outcome,prUrl,at}
#   runner.pid       pid + the in-flight sessionId + kern.boottime
#   runner.log
#   bin/menubar      built on demand from scripts/autopilot-menubar.swift
#
# 0700 throughout. The queue names real tickets and real repos, and on a shared
# machine that is somebody's roadmap.
#
# Usage:
#   . autopilot-state.sh
#   ma_ap_root                 # prints the dir, creating it 0700 only when asked
#   ma_ap_is_on                # exit 0 when configured, 1 otherwise. NO side effects
#   ma_ap_write <file> <-      # atomic write from stdin, 0600
#   ma_ap_asset <rel>          # resolve a script/lib/template across the 3 host roots
#   ma_ap_command <sub>        # the subcommand, spelled the way this host spells it

set -uo pipefail

# The spelling of a command, and the language a sentence renders in, are one
# table for the whole pipeline. Sourcing it here is what keeps this file from
# carrying a second copy of either.
# shellcheck source=pipeline/lib/user-facing.sh
if [ -f "$(dirname "${BASH_SOURCE[0]}")/user-facing.sh" ]; then
  . "$(dirname "${BASH_SOURCE[0]}")/user-facing.sh"
elif [ -f "$HOME/.claude/lib/user-facing.sh" ]; then
  . "$HOME/.claude/lib/user-facing.sh"
fi

MA_AP_ROOT="${MA_AUTOPILOT_ROOT:-$HOME/.claude/autopilot}"

ma_ap_root() { printf '%s\n' "$MA_AP_ROOT"; }

# Where the scripts, libs and templates this mode needs actually live.
#
# This file ships into whichever host tree installed it, and a Copilot-only or
# Codex-only machine has no ~/.claude/scripts, no ~/.claude/lib and - the one
# that bites hardest - no ~/.claude/templates, which only install/claude.mjs
# creates. phase-tracker.sh already carries this resolver and its comment names
# what happens without it: "a hard-coded path silently disabled every live ping
# on those hosts". Autopilot reproduced the class; this is the same answer.
#
# The STATE root above is deliberately NOT resolved this way. ~/.claude/autopilot/
# is shared across hosts on purpose, exactly like logs/, knowledge/ and
# multi-agent-preferences.json, because two CLIs on one machine must see ONE
# queue. Resolving it per host would hand a Copilot session a second, invisible
# queue and the same item would be taken twice.
MA_AP_HOST_ROOT="${MA_AP_HOST_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)}"

ma_ap_asset() { # $1 = path relative to a host root, e.g. scripts/autopilot-runner.mjs
  local rel="$1" root
  # The tree this file was sourced from wins: on a machine with all three
  # installed, the caller's own tree is the one that is current.
  if [ -n "$MA_AP_HOST_ROOT" ] && [ -e "$MA_AP_HOST_ROOT/$rel" ]; then
    printf '%s\n' "$MA_AP_HOST_ROOT/$rel"
    return 0
  fi
  for root in "$HOME/.claude" "$HOME/.copilot" "$HOME/.codex"; do
    if [ -e "$root/$rel" ]; then
      printf '%s\n' "$root/$rel"
      return 0
    fi
  done
  return 1
}

# The three CLIs spell the same command three ways, and the tree this file was
# sourced from is the one in front of the user. A status line that names a
# command names the one this machine can actually type. The table itself is
# `ma_command` in user-facing.sh - one place, so a fourth host is one edit.
ma_ap_command() { # $1 = subcommand, e.g. autopilot-off
  ma_command "$1" "$MA_AP_HOST_ROOT"
}

MA_AP_LABEL="${MA_AUTOPILOT_LABEL:-com.multi-agent.autopilot}"
MA_AP_PLIST="${MA_AUTOPILOT_PLIST:-$HOME/Library/LaunchAgents/$MA_AP_LABEL.plist}"

# Two different questions, and conflating them was a real bug in the first draft
# of this file: `autopilot-off` deliberately KEEPS config.json so turning the
# mode back on does not re-ask which repos, so a predicate reading the config
# would still answer "on" after you turned it off.
#
#   configured = a repo selection exists
#   on         = launchd holds the job
#
# On is the launchd job because that is the thing that actually makes work
# happen; anything else is a claim about a file.
ma_ap_is_configured() { [ -f "$MA_AP_ROOT/config.json" ]; }

ma_ap_is_on() { [ -f "$MA_AP_PLIST" ] && launchctl list 2>/dev/null | grep -q "$MA_AP_LABEL"; }

# The failure mode a `resume` command would have papered over: configured and
# meant to be running, but launchd does not hold the job - an OS update dropped
# the plist, or it was booted out by hand. doctor reports this; there is no
# command to remember.
ma_ap_is_orphaned() { ma_ap_is_configured && ! ma_ap_is_on && [ -f "$MA_AP_PLIST" ]; }

# Deliberately no side effects in any of the three. A predicate that creates its
# own directory turns "is autopilot on?" into "autopilot is now half on", and
# every status command, hook and doctor check calls these.

ma_ap_ensure_root() {
  [ -d "$MA_AP_ROOT" ] || mkdir -p "$MA_AP_ROOT" || return 1
  chmod 700 "$MA_AP_ROOT" 2>/dev/null
  [ -d "$MA_AP_ROOT/bin" ] || mkdir -p "$MA_AP_ROOT/bin" 2>/dev/null
  chmod 700 "$MA_AP_ROOT/bin" 2>/dev/null
  return 0
}

# Write via a temp file in the SAME directory then rename. The menu bar polls
# status.json every few seconds, and a reader that catches a half-written file
# shows an empty menu; rename is atomic on the same filesystem, so it never sees
# a partial one.
ma_ap_write() { # $1 = filename under the root; content on stdin
  ma_ap_ensure_root || return 1
  local dst="$MA_AP_ROOT/$1" tmp="$MA_AP_ROOT/.$1.$$"
  cat > "$tmp" || {
    rm -f "$tmp"
    return 1
  }
  chmod 600 "$tmp" 2>/dev/null
  mv -f "$tmp" "$dst"
}

ma_ap_append() { # $1 = filename, content on stdin - for the jsonl
  ma_ap_ensure_root || return 1
  local dst="$MA_AP_ROOT/$1"
  cat >> "$dst" || return 1
  chmod 600 "$dst" 2>/dev/null
}

ma_ap_read() { # $1 = filename; empty and exit 1 when absent
  local src="$MA_AP_ROOT/$1"
  [ -f "$src" ] || return 1
  cat "$src"
}

# jq with a default, so a caller never has to distinguish "key absent" from
# "file absent" from "file unparseable" - all three mean "use the default".
ma_ap_cfg() { # $1 = jq path, $2 = default
  # An unreadable queue must not read as an EMPTY queue. Without this, a
  # machine with no jq made the runner conclude there was no work and go
  # quiet - the worst failure this mode can have, because it looks like
  # success.
  if ! command -v jq >/dev/null 2>&1; then
    echo "jq not found - cannot read the autopilot config (install: brew install jq)" >&2
    return 3
  fi
  local v
  v=$(ma_ap_read config.json 2>/dev/null | jq -r "$1 // empty" 2>/dev/null)
  [ -n "$v" ] && printf '%s\n' "$v" || printf '%s\n' "$2"
}

# AC or battery. The mode holds a sleep assertion only on AC: a queue that keeps
# a laptop awake on battery is a bug, and `StartInterval` does not wake a
# sleeping Mac anyway, so on battery the work resumes when you plug in.
ma_ap_power() {
  case "$(pmset -g ps 2>/dev/null | head -1)" in
    *"AC Power"*) printf 'ac\n' ;;
    *) printf 'battery\n' ;;
  esac
}

# Boot time, so a stale runner.pid cannot be mistaken for a live runner. After a
# restart pids start low and the recorded 4711 may belong to something unrelated;
# a pid recorded BEFORE the current boot is stale by definition, with no probing.
# The pattern is ANCHORED on purpose. `kern.boottime` prints
# `{ sec = 1788181644, usec = 618574 } Mon Aug 31 ...`, and `.*sec = ` is greedy:
# it walks past `sec` to `usec` and captures 618574, the microseconds. The
# original form here did exactly that, so this returned a six-digit number that
# looked plausible and never matched the same fact read anywhere else - which is
# how the runner's staleness check compared two different numbers and called a
# live runner dead.
ma_ap_boottime() {
  sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ *sec = \([0-9]*\).*/\1/p'
}

# The machine's honest ceiling, so raising `slots` is a measured decision. RAM
# and cores bound the concurrent Claude sessions; disk bounds the worktrees
# (0.75 GB each, measured at 11 GB across 15). The hard cap is 4 because beyond
# that the bottleneck stops being this machine.
ma_ap_slot_ceiling() {
  local ram_gb cores free_gb a b c
  ram_gb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1073741824 ))
  cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 2)
  free_gb=$(df -g "$HOME" 2>/dev/null | awk 'NR==2{print $4}')
  [ -n "$free_gb" ] || free_gb=0
  a=$(((ram_gb - 4) * 10 / 25))
  b=$((cores / 2))
  c=$(((free_gb - 20) * 100 / 75))
  local m=$a
  [ "$b" -lt "$m" ] && m=$b
  [ "$c" -lt "$m" ] && m=$c
  [ "$m" -gt 4 ] && m=4
  [ "$m" -lt 1 ] && m=1
  printf '%s\n' "$m"
}
