1#!/usr/bin/env bash
One line of status for a once-a-minute watchdog. See ./README.md.
Exit codes: 0 asking works, or is throttled and will clear by itself; 2
stopped for good (the lifetime cap, or the vendor said out of credit); 3
the window did not answer at all (no session); 4 the window answered but a
safety-relevant field (budget, or whether asking is stopped) could not be
read - an absent tool on an old build looks exactly like this, and MUST NOT
be read as "not stopped" or "$0.00 left". --self-test runs the parsing/formatting logic against recorded
responses, with no network at all - see self_test below for the cases.
11set -euo pipefail
13MCP_URL="http://127.0.0.1:7637/mcp"
The reason the last parse_tool_response/mcp_tool failure, in a FILE
rather than a variable. Every caller that needs it captures the function's
STDOUT with $(...), which runs it in a subshell - a variable set there is
invisible to the parent shell the instant the subshell exits. A file
survives that; this bit the first version of this script (a real budget
call against an old build, missing the tool, produced "TOOL_ERROR: unbound
variable" instead of the message).
--- pure: parsing and formatting, exercised by --self-test with no network ---
One top-level field, as text, or the literal string "unknown" - never a
silent numeric default. has($k) plus an explicit null check is what lets
this tell a real false (known) apart from a missing or null field
(unknown); jq -e '.foo' alone cannot, because it treats false and
null identically as "no output".
One raw SSE/JSON-RPC response body -> the tool's own JSON content on
stdout (exit 0), or nothing with the reason in last_error (exit 1). A
JSON-RPC error (an unknown tool, on an old build, looks exactly like
this), a tool-level isError, and a body that is not this shape at all
are three different ways to fail and all three are handled - none of them
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}
"true", "false" or "unknown" - kept apart from the human text below because the caller needs the bare status to decide the exit code, not the sentence. Only a stop that waiting cannot clear is "true"; a throttle heals itself.
What is stopping questions now, or "no". stopped is null when nothing is,
which get reads as "unknown", so it is only trusted beside a known
stopped_for_good.
--- impure: the network round trip ---
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}
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"
Regression, found against the real window 2026-09-20: the stream's
first SSE event is a bare keepalive (data: with nothing after),
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"
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