1#!/usr/bin/env bash 2# One line of status for a once-a-minute watchdog. See ./README.md. 3# 4# Exit codes: 0 asking works, or is throttled and will clear by itself; 2 5# stopped for good (the lifetime cap, or the vendor said out of credit); 3 6# the window did not answer at all (no session); 4 the window answered but a 7# safety-relevant field (budget, or whether asking is stopped) could not be 8# read - an absent tool on an old build looks exactly like this, and MUST NOT 9# be read as "not stopped" or "$0.00 left". `--self-test` runs the parsing/formatting logic against recorded 10# responses, with no network at all - see `self_test` below for the cases. 11set -euo pipefail 12 13MCP_URL="http://127.0.0.1:7637/mcp" 14 15# The reason the last `parse_tool_response`/`mcp_tool` failure, in a FILE 16# rather than a variable. Every caller that needs it captures the function's 17# STDOUT with `$(...)`, which runs it in a subshell - a variable set there is 18# invisible to the parent shell the instant the subshell exits. A file 19# survives that; this bit the first version of this script (a real `budget` 20# call against an old build, missing the tool, produced "TOOL_ERROR: unbound 21# variable" instead of the message). 22ERROR_FILE="$(mktemp)" 23trap 'rm -f "$ERROR_FILE"' EXIT 24set_error() { printf '%s' "$1" >"$ERROR_FILE"; } 25last_error() { cat "$ERROR_FILE" 2>/dev/null || true; } 26 27# --- pure: parsing and formatting, exercised by --self-test with no network --- 28 29# One top-level field, as text, or the literal string "unknown" - never a 30# silent numeric default. `has($k)` plus an explicit null check is what lets 31# this tell a real `false` (known) apart from a missing or null field 32# (unknown); `jq -e '.foo'` alone cannot, because it treats `false` and 33# `null` identically as "no output". 34get() { # $1 = json $2 = key -> value as text, or "unknown" 35 jq -r --arg k "$2" \ 36 'if (has($k)) and (.[$k] != null) then (.[$k] | tostring) else "unknown" end' \ 37 <<<"$1" 2>/dev/null || echo "unknown" 38} 39 40# One raw SSE/JSON-RPC response body -> the tool's own JSON content on 41# stdout (exit 0), or nothing with the reason in `last_error` (exit 1). A 42# JSON-RPC `error` (an unknown tool, on an old build, looks exactly like 43# this), a tool-level `isError`, and a body that is not this shape at all 44# are three different ways to fail and all three are handled - none of them 45# may fall through to being read as a value. 46parse_tool_response() { # $1 = raw body 47 local line json result 48 # The stream's first event is often a bare keepalive (`data: ` with 49 # nothing after it, seen for real against the live window, 2026-09-20) - 50 # `{` in the pattern is what skips that and finds the actual JSON-RPC 51 # payload, the same way tools/hotpatch/patch.sh's own `mcp_raw` does. 52 line="$(grep -m1 '^data: {' <<<"$1" || true)" 53 [[ -n "$line" ]] || { set_error "no data: {...} line in the response"; return 1; } 54 json="${line#data: }" 55 jq -e . >/dev/null 2>&1 <<<"$json" || { set_error "the data: line was not JSON"; return 1; } 56 if jq -e '.error' >/dev/null 2>&1 <<<"$json"; then 57 set_error "$(jq -r '.error.message // "a JSON-RPC error with no message"' <<<"$json")" 58 return 1 59 fi 60 jq -e '.result' >/dev/null 2>&1 <<<"$json" || { set_error "no result and no error"; return 1; } 61 result="$(jq -c '.result' <<<"$json")" 62 if [[ "$(get "$result" isError)" == "true" ]]; then 63 set_error "$(jq -r '.content[0].text // "the tool reported an error with no text"' <<<"$result")" 64 return 1 65 fi 66 jq -r '.content[0].text // empty' <<<"$result" 67} 68 69format_lifetime() { # $1 = budget json -> "$X.XX" or "unknown" 70 local v 71 v="$(get "$1" lifetime_remaining_usd)" 72 [[ "$v" == "unknown" ]] && { echo "unknown"; return; } 73 printf '$%.2f\n' "$v" 74} 75 76# "true", "false" or "unknown" - kept apart from the human text below because 77# the caller needs the bare status to decide the exit code, not the sentence. 78# Only a stop that waiting cannot clear is "true"; a throttle heals itself. 79stopped_status() { # $1 = budget json 80 get "$1" stopped_for_good 81} 82 83# What is stopping questions now, or "no". `stopped` is null when nothing is, 84# which `get` reads as "unknown", so it is only trusted beside a known 85# `stopped_for_good`. 86format_stopped() { # $1 = budget json 87 local why 88 why="$(get "$1" stopped)" 89 case "$(stopped_status "$1")" in 90 true) echo "for good: $why" ;; 91 false) if [[ "$why" == "unknown" ]]; then echo "no"; else echo "$why"; fi ;; 92 *) echo "unknown" ;; 93 esac 94} 95 96# --- impure: the network round trip --- 97 98mcp_tool() { # $1 = tool name -> its content on stdout, or sets the error (see last_error) and fails 99 local body extra=() 100 [[ -n "${SESSION:-}" ]] && extra+=(-H "Mcp-Session-Id: $SESSION") 101 body="$(curl -sS --max-time 5 -X POST "$MCP_URL" \ 102 -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ 103 "${extra[@]}" \ 104 -d "$(jq -nc --arg name "$1" \ 105 '{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:$name,arguments:{}}}')")" \ 106 || { set_error "curl could not reach $MCP_URL"; return 1; } 107 parse_tool_response "$body" 108} 109 110connect() { # sets SESSION, or exits 3 111 local headers 112 headers="$(mktemp)" 113 trap 'rm -f "$headers"' RETURN 114 curl -sS --max-time 5 -D "$headers" -X POST "$MCP_URL" \ 115 -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ 116 -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"budget.sh","version":"0"}}}' \ 117 >/dev/null || { echo "budget.sh: could not reach $MCP_URL - is a window running?" >&2; exit 3; } 118 SESSION="$(grep -i '^mcp-session-id:' "$headers" | tr -d '\r' | awk '{print $2}')" 119 [[ -n "$SESSION" ]] || { echo "budget.sh: no Mcp-Session-Id from $MCP_URL - is a window running?" >&2; exit 3; } 120 curl -sS --max-time 5 -X POST "$MCP_URL" -H 'Content-Type: application/json' \ 121 -H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SESSION" \ 122 -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null || true 123} 124 125main() { 126 SESSION="" 127 connect 128 local state budget goal exit_code=0 129 if ! state="$(mcp_tool state)"; then 130 echo "budget.sh: state unavailable: $(last_error)" >&2 131 exit 4 132 fi 133 if ! budget="$(mcp_tool budget)"; then 134 echo "budget.sh: budget unavailable: $(last_error)" >&2 135 exit 4 136 fi 137 if goal="$(mcp_tool bot)"; then 138 goal="$(jq -r '.tasks[0] // "-"' <<<"$goal" 2>/dev/null || echo "-")" 139 else 140 goal="-" 141 fi 142 curl -sS -o /dev/null --max-time 5 -X DELETE "$MCP_URL" -H "Mcp-Session-Id: $SESSION" || true 143 144 case "$(stopped_status "$budget")" in 145 true) exit_code=2 ;; 146 false) exit_code=0 ;; 147 *) exit_code=4 ;; 148 esac 149 printf '%s task=%s mode=%s link=%s,%s lifetime_remaining=%s q/min=%s stopped=%s\n' \ 150 "$(date -u +%FT%TZ)" "$goal" "$(get "$state" mode)" \ 151 "$(get "$state" link_x)" "$(get "$state" link_y)" \ 152 "$(format_lifetime "$budget")" "$(get "$budget" questions_last_minute)" \ 153 "$(format_stopped "$budget")" 154 exit "$exit_code" 155} 156 157self_test() { 158 local failures=0 159 check() { # $1 = description $2 = actual $3 = expected 160 if [[ "$2" == "$3" ]]; then 161 echo "ok - $1" 162 else 163 echo "FAIL - $1: got [$2] want [$3]" >&2 164 failures=$((failures + 1)) 165 fi 166 } 167 content() { # $1 = the tool's own JSON, as a shell string -> a recorded SSE body carrying it 168 printf 'event: message\r\ndata: %s\r\n\r\n' \ 169 "$(jq -nc --arg text "$1" '{jsonrpc:"2.0",id:1,result:{content:[{type:"text",text:$text}]}}')" 170 } 171 172 local budget_ok budget_throttled budget_capped state_ok 173 budget_ok="$(parse_tool_response "$(content '{"lifetime_remaining_usd":4.0,"stopped":null,"stopped_for_good":false,"questions_last_minute":0}')")" 174 check "a present tool parses" "$(get "$budget_ok" lifetime_remaining_usd)" "4.0" 175 check "an explicit false is KNOWN, not unknown - the bug this exists for" \ 176 "$(stopped_status "$budget_ok")" "false" 177 check "a known false with nothing stopping formats as no" "$(format_stopped "$budget_ok")" "no" 178 179 budget_throttled="$(parse_tool_response "$(content '{"lifetime_remaining_usd":3.5,"stopped":"throttled: bucket empty, next question in 12s","stopped_for_good":false,"questions_last_minute":30}')")" 180 check "a throttle is not a stop for good" "$(stopped_status "$budget_throttled")" "false" 181 check "but says why" "$(format_stopped "$budget_throttled")" "throttled: bucket empty, next question in 12s" 182 183 budget_capped="$(parse_tool_response "$(content '{"lifetime_remaining_usd":0.0,"stopped":"lifetime cap reached - stopped for good","stopped_for_good":true,"questions_last_minute":0}')")" 184 check "the cap is a stop for good" "$(stopped_status "$budget_capped")" "true" 185 check "the reason is carried" "$(format_stopped "$budget_capped")" "for good: lifetime cap reached - stopped for good" 186 check "a real low balance still formats as a number" "$(format_lifetime "$budget_capped")" "\$0.00" 187 188 state_ok="$(parse_tool_response "$(content '{"mode":"Overworld","link_x":1000,"link_y":800}')")" 189 check "state parses" "$(get "$state_ok" mode)" "Overworld" 190 191 # Regression, found against the real window 2026-09-20: the stream's 192 # first SSE event is a bare keepalive (`data: ` with nothing after), 193 # before the actual JSON-RPC payload's own `data: {...}` event. 194 local with_keepalive 195 with_keepalive="$(printf 'data: \r\nid: 0\r\nretry: 3000\r\n\r\n')$(content '{"mode":"Overworld","link_x":1000,"link_y":800}')" 196 check "a leading keepalive event is skipped, not read as the payload" \ 197 "$(get "$(parse_tool_response "$with_keepalive")" mode)" "Overworld" 198 199 local missing 200 missing='event: message 201data: {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"no tool named \"budget\""}} 202 203' 204 if parse_tool_response "$missing" >/dev/null 2>&1; then 205 check "a missing tool must be treated as a failure" "parsed" "should have failed" 206 else 207 check "a missing tool's message is surfaced" "$(last_error)" 'no tool named "budget"' 208 fi 209 check "a missing tool never renders as a number" "$(format_lifetime '{}')" "unknown" 210 check "an absent field is unknown, not stopped=no" "$(format_stopped '{}')" "unknown" 211 check "an absent field's exit status is not the false case" "$(stopped_status '{}')" "unknown" 212 213 ((failures == 0)) && { echo "self-test: all ok"; return 0; } 214 echo "self-test: $failures failure(s)" >&2 215 return 1 216} 217 218if [[ "${1:-}" == "--self-test" ]]; then 219 self_test 220else 221 main 222fi