Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 10 additions & 155 deletions airc
Original file line number Diff line number Diff line change
Expand Up @@ -2645,138 +2645,16 @@ cmd_version() {
echo " install: $dir"
}

cmd_status() {
# Human-readable liveness view. Fast — no network calls by default; `--probe`
# opts into a 3s SSH reachability check.
ensure_init
local probe=0
[ "${1:-}" = "--probe" ] && probe=1

local my_name host_target host_name host_port
my_name=$(get_name)
host_target=$(get_config_val host_target "")
host_name=$(get_config_val host_name "")
host_port=$(get_config_val host_port 7547)

echo " airc status — scope $AIRC_WRITE_DIR"

# Identity + role line.
if [ -n "$host_target" ]; then
echo " identity: $my_name (joiner of ${host_name:-?} @ ${host_target}:${host_port})"
else
local my_port; my_port="${AIRC_PORT:-7547}"
[ -f "$AIRC_WRITE_DIR/host_port" ] && my_port=$(cat "$AIRC_WRITE_DIR/host_port" 2>/dev/null)
echo " identity: $my_name (hosting on port ${my_port})"
fi

# Monitor alive? Read the scope's pidfile — cmd_connect writes its own PID
# there. pgrep'd descendants (python listener, tail loop) should be children
# of that PID. If the main PID is gone, the monitor is down.
local monitor_state="not running"
local pidfile="$AIRC_WRITE_DIR/airc.pid"
if [ -f "$pidfile" ]; then
# cmd_connect writes multiple space-separated PIDs on one line (parent +
# python listener). Monitor is "running" if ANY of them is alive.
local pids_raw; pids_raw=$(cat "$pidfile" 2>/dev/null | tr '\n' ' ' || true)
local any_alive=""
for p in $pids_raw; do
if kill -0 "$p" 2>/dev/null; then any_alive="$p"; break; fi
done
if [ -n "$any_alive" ]; then
monitor_state="running (PID $any_alive)"
else
monitor_state="stale pidfile (PIDs $pids_raw not alive — run 'airc connect' to self-heal)"
fi
fi
echo " monitor: $monitor_state"

# Host reachability. Only meaningful for joiners; opt-in via --probe to keep
# `airc status` fast by default (SSH connect can hang for seconds).
if [ -n "$host_target" ] && [ "$probe" = "1" ]; then
local ssh_key="$IDENTITY_DIR/ssh_key"
local probe_out
probe_out=$(ssh -i "$ssh_key" -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=3 -o BatchMode=yes \
"$host_target" "echo __REACHABLE__" 2>/dev/null || true)
if echo "$probe_out" | grep -q '^__REACHABLE__$'; then
echo " host: reachable"
else
echo " host: UNREACHABLE (ssh timeout or auth failure)"
fi
fi

# Last send / receive timestamps. last_sent is a unix epoch written by
# cmd_send. last receive: tail the local messages.jsonl for the most recent
# inbound line (from != $my_name).
local now; now=$(date +%s)
if [ -f "$AIRC_WRITE_DIR/last_sent" ]; then
local ls; ls=$(cat "$AIRC_WRITE_DIR/last_sent" 2>/dev/null)
if [ -n "$ls" ] && [ "$ls" -gt 0 ] 2>/dev/null; then
echo " last send: $(( now - ls ))s ago"
else
echo " last send: never"
fi
else
echo " last send: never"
fi

if [ -s "$MESSAGES" ]; then
local last_rx_ts
last_rx_ts=$(PEERS_DIR="$PEERS_DIR" MY_NAME="$my_name" "$AIRC_PYTHON" -c "
import sys, json, os, calendar, time
name = os.environ.get('MY_NAME', '')
last_ts = None
try:
with open('$MESSAGES') as f:
for line in f:
try:
m = json.loads(line)
if m.get('from') and m.get('from') != name and m.get('from') != 'airc':
last_ts = m.get('ts')
except: pass
except: pass
if last_ts:
# ts is ISO8601 UTC (Z-suffix). Convert to epoch.
try:
t = time.strptime(last_ts.replace('Z',''), '%Y-%m-%dT%H:%M:%S')
print(int(calendar.timegm(t)))
except: print('')
else:
print('')
" 2>/dev/null)
if [ -n "$last_rx_ts" ]; then
echo " last recv: $(( now - last_rx_ts ))s ago"
else
echo " last recv: never"
fi
else
echo " last recv: never"
fi

# Pending queue — how many sends are waiting for a drain. Populated by
# cmd_send's wire-failure branch; drained by flush_pending_loop.
local pending="$AIRC_WRITE_DIR/pending.jsonl"
local pending_count=0
[ -f "$pending" ] && pending_count=$(grep -c '^.' "$pending" 2>/dev/null || echo 0)
if [ "$pending_count" -gt 0 ]; then
echo " queue: ${pending_count} pending (auto-retries every ~5s)"
else
echo " queue: empty"
fi

# Reminder state
local reminder_file="$AIRC_WRITE_DIR/reminder"
if [ -f "$reminder_file" ]; then
local rv; rv=$(cat "$reminder_file" 2>/dev/null)
if [ "$rv" = "0" ]; then
echo " reminder: paused"
elif [ -n "$rv" ] && [ "$rv" -gt 0 ] 2>/dev/null; then
echo " reminder: every ${rv}s"
fi
else
echo " reminder: off"
fi
}
# cmd_status + cmd_logs extracted to lib/airc_bash/cmd_status.sh
# (#152 Phase 3 file split). cmd_logs lived ~30 lines below the cmd_doctor
# source line; this single source-block provides BOTH functions.
if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_status.sh" ]; then
# shellcheck source=lib/airc_bash/cmd_status.sh
source "$_airc_lib_dir/airc_bash/cmd_status.sh"
else
echo "ERROR: airc_bash/cmd_status.sh not found via lib-dir resolver." >&2
exit 1
fi

# cmd_daemon family extracted to lib/airc_bash/cmd_daemon.sh
# (#152 Phase 3 file split, follow-up to cmd_doctor.sh / cmd_connect.sh).
Expand All @@ -2802,29 +2680,6 @@ else
fi


cmd_logs() {
ensure_init
local count="${1:-20}"
local host_target
host_target=$(get_config_val host_target "")

local raw
if [ -n "$host_target" ]; then
local rhome; rhome=$(remote_home)
raw=$(relay_ssh "$host_target" "tail -${count} $rhome/messages.jsonl 2>/dev/null" 2>/dev/null) || true
else
raw=$(tail -"$count" "$MESSAGES" 2>/dev/null) || true
fi
echo "$raw" | "$AIRC_PYTHON" -c "
import sys, json
for line in sys.stdin:
try:
m = json.loads(line.strip())
print(f\"[{m.get('ts','')}] {m.get('from','?')}: {m.get('msg','')}\")
except: pass
"
}

# ── Dispatch ────────────────────────────────────────────────────────────

case "${1:-help}" in
Expand Down
170 changes: 170 additions & 0 deletions lib/airc_bash/cmd_status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Sourced by airc. cmd_status + cmd_logs — introspection verbs.
#
# Functions exported back to airc's dispatch:
# cmd_status — human-readable liveness snapshot. Fast (no network)
# by default; `--probe` adds an SSH host check.
# cmd_logs — tail messages.jsonl. Falls back to host's log via
# ssh when not the host.
#
# Both are read-only introspection and share no helpers, but live in
# the same conceptual group ("what is happening?"). External cross-
# references (call-time): die, ensure_init, get_config_val, relay_ssh,
# remote_home, MESSAGES, AIRC_PYTHON.
Comment on lines +11 to +12
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header comment’s “External cross-references” list is now incomplete: this file also calls get_name and references IDENTITY_DIR. Keeping this list accurate helps when auditing module dependencies during the ongoing monolith split.

Suggested change
# references (call-time): die, ensure_init, get_config_val, relay_ssh,
# remote_home, MESSAGES, AIRC_PYTHON.
# references (call-time): die, ensure_init, get_config_val, get_name,
# relay_ssh, remote_home, MESSAGES, AIRC_PYTHON, IDENTITY_DIR.

Copilot uses AI. Check for mistakes.
#
# Extracted from airc as part of #152 Phase 3 file split.

cmd_status() {
# Human-readable liveness view. Fast — no network calls by default; `--probe`
# opts into a 3s SSH reachability check.
ensure_init
local probe=0
[ "${1:-}" = "--probe" ] && probe=1

local my_name host_target host_name host_port
my_name=$(get_name)
host_target=$(get_config_val host_target "")
host_name=$(get_config_val host_name "")
host_port=$(get_config_val host_port 7547)

echo " airc status — scope $AIRC_WRITE_DIR"

# Identity + role line.
if [ -n "$host_target" ]; then
echo " identity: $my_name (joiner of ${host_name:-?} @ ${host_target}:${host_port})"
else
local my_port; my_port="${AIRC_PORT:-7547}"
[ -f "$AIRC_WRITE_DIR/host_port" ] && my_port=$(cat "$AIRC_WRITE_DIR/host_port" 2>/dev/null)
echo " identity: $my_name (hosting on port ${my_port})"
fi

# Monitor alive? Read the scope's pidfile — cmd_connect writes its own PID
# there. pgrep'd descendants (python listener, tail loop) should be children
# of that PID. If the main PID is gone, the monitor is down.
local monitor_state="not running"
local pidfile="$AIRC_WRITE_DIR/airc.pid"
if [ -f "$pidfile" ]; then
# cmd_connect writes multiple space-separated PIDs on one line (parent +
# python listener). Monitor is "running" if ANY of them is alive.
local pids_raw; pids_raw=$(cat "$pidfile" 2>/dev/null | tr '\n' ' ' || true)
local any_alive=""
for p in $pids_raw; do
if kill -0 "$p" 2>/dev/null; then any_alive="$p"; break; fi
done
if [ -n "$any_alive" ]; then
monitor_state="running (PID $any_alive)"
else
monitor_state="stale pidfile (PIDs $pids_raw not alive — run 'airc connect' to self-heal)"
fi
fi
echo " monitor: $monitor_state"

# Host reachability. Only meaningful for joiners; opt-in via --probe to keep
# `airc status` fast by default (SSH connect can hang for seconds).
if [ -n "$host_target" ] && [ "$probe" = "1" ]; then
local ssh_key="$IDENTITY_DIR/ssh_key"
local probe_out
probe_out=$(ssh -i "$ssh_key" -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=3 -o BatchMode=yes \
"$host_target" "echo __REACHABLE__" 2>/dev/null || true)
if echo "$probe_out" | grep -q '^__REACHABLE__$'; then
echo " host: reachable"
else
echo " host: UNREACHABLE (ssh timeout or auth failure)"
fi
fi

# Last send / receive timestamps. last_sent is a unix epoch written by
# cmd_send. last receive: tail the local messages.jsonl for the most recent
# inbound line (from != $my_name).
local now; now=$(date +%s)
if [ -f "$AIRC_WRITE_DIR/last_sent" ]; then
local ls; ls=$(cat "$AIRC_WRITE_DIR/last_sent" 2>/dev/null)
if [ -n "$ls" ] && [ "$ls" -gt 0 ] 2>/dev/null; then
echo " last send: $(( now - ls ))s ago"
else
echo " last send: never"
fi
else
echo " last send: never"
fi

if [ -s "$MESSAGES" ]; then
local last_rx_ts
last_rx_ts=$(PEERS_DIR="$PEERS_DIR" MY_NAME="$my_name" "$AIRC_PYTHON" -c "
import sys, json, os, calendar, time
name = os.environ.get('MY_NAME', '')
last_ts = None
try:
with open('$MESSAGES') as f:
for line in f:
try:
m = json.loads(line)
if m.get('from') and m.get('from') != name and m.get('from') != 'airc':
last_ts = m.get('ts')
except: pass
except: pass
if last_ts:
# ts is ISO8601 UTC (Z-suffix). Convert to epoch.
try:
t = time.strptime(last_ts.replace('Z',''), '%Y-%m-%dT%H:%M:%S')
print(int(calendar.timegm(t)))
except: print('')
else:
print('')
" 2>/dev/null)
if [ -n "$last_rx_ts" ]; then
echo " last recv: $(( now - last_rx_ts ))s ago"
else
echo " last recv: never"
fi
else
echo " last recv: never"
fi

# Pending queue — how many sends are waiting for a drain. Populated by
# cmd_send's wire-failure branch; drained by flush_pending_loop.
local pending="$AIRC_WRITE_DIR/pending.jsonl"
local pending_count=0
[ -f "$pending" ] && pending_count=$(grep -c '^.' "$pending" 2>/dev/null || echo 0)
if [ "$pending_count" -gt 0 ]; then
echo " queue: ${pending_count} pending (auto-retries every ~5s)"
else
echo " queue: empty"
fi
Comment on lines +124 to +133
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending_count is computed with grep -c ... || echo 0. When the file exists but has 0 matching lines, grep -c prints 0 and exits 1, so the || echo 0 appends a second 0, yielding a non-integer value like "0 0" and causing [ "$pending_count" -gt 0 ] to emit an error. Use a counting approach that doesn't double-print on exit-1 (e.g. wc -l < file, or grep -c with || true and a separate default).

Copilot uses AI. Check for mistakes.

# Reminder state
local reminder_file="$AIRC_WRITE_DIR/reminder"
if [ -f "$reminder_file" ]; then
local rv; rv=$(cat "$reminder_file" 2>/dev/null)
if [ "$rv" = "0" ]; then
echo " reminder: paused"
elif [ -n "$rv" ] && [ "$rv" -gt 0 ] 2>/dev/null; then
echo " reminder: every ${rv}s"
fi
else
echo " reminder: off"
fi
}

cmd_logs() {
ensure_init
local count="${1:-20}"
local host_target
host_target=$(get_config_val host_target "")

local raw
if [ -n "$host_target" ]; then
local rhome; rhome=$(remote_home)
raw=$(relay_ssh "$host_target" "tail -${count} $rhome/messages.jsonl 2>/dev/null" 2>/dev/null) || true
else
raw=$(tail -"$count" "$MESSAGES" 2>/dev/null) || true
Comment on lines +152 to +160
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count is passed directly into tail (locally and inside the remote command). Since it comes from argv, a non-numeric value can break tail parsing, and in the remote case it is interpolated unquoted into a shell command, enabling command injection via something like airc logs '20; ...'. Validate count is an integer (or support explicit --count N parsing) and build the tail invocation in a way that prevents shell injection (e.g. tail -n with a validated number, and quote the remote path).

Suggested change
local host_target
host_target=$(get_config_val host_target "")
local raw
if [ -n "$host_target" ]; then
local rhome; rhome=$(remote_home)
raw=$(relay_ssh "$host_target" "tail -${count} $rhome/messages.jsonl 2>/dev/null" 2>/dev/null) || true
else
raw=$(tail -"$count" "$MESSAGES" 2>/dev/null) || true
case "$count" in
''|*[!0-9]*)
echo "airc logs: count must be a non-negative integer" >&2
return 1
;;
esac
local host_target
host_target=$(get_config_val host_target "")
local raw
if [ -n "$host_target" ]; then
local rhome remote_messages remote_messages_quoted
rhome=$(remote_home)
remote_messages="$rhome/messages.jsonl"
remote_messages_quoted=$(printf "%s" "$remote_messages" | sed "s/'/'\\\\''/g")
raw=$(relay_ssh "$host_target" "tail -n $count '$remote_messages_quoted' 2>/dev/null" 2>/dev/null) || true
else
raw=$(tail -n "$count" "$MESSAGES" 2>/dev/null) || true

Copilot uses AI. Check for mistakes.
fi
echo "$raw" | "$AIRC_PYTHON" -c "
import sys, json
for line in sys.stdin:
try:
m = json.loads(line.strip())
print(f\"[{m.get('ts','')}] {m.get('from','?')}: {m.get('msg','')}\")
except: pass
"
}
Loading