From dcffe3aae63c4ba2e7662af9554df2e0cca56c9a Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:12:22 -0500 Subject: [PATCH 01/17] fix(tmux): give pane-find's mktemp template Xs so it works on GNU coreutils mktemp -d -t panefind fails outright with GNU mktemp, which requires at least three trailing Xs in a -t template, breaking prefix+F on the Linux boxes. Spell out the full path with a XXXXXXXX suffix instead. --- .tmux/pane-find.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.tmux/pane-find.sh b/.tmux/pane-find.sh index 0b93e9b..b2aafcc 100755 --- a/.tmux/pane-find.sh +++ b/.tmux/pane-find.sh @@ -161,7 +161,8 @@ case ${1-} in render_tree "${2-}"; exit 0 ;; esac -PANEFIND_DIR=$(mktemp -d -t panefind) +# A fixed template without XXXX fails on GNU mktemp, so spell the suffix out. +PANEFIND_DIR=$(mktemp -d "${TMPDIR:-/tmp}/panefind.XXXXXX") export PANEFIND_DIR trap 'rm -rf "$PANEFIND_DIR"' EXIT echo title > "$PANEFIND_DIR/mode" From bf06812479954611626a66f07863f9d2b153928e Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:12:40 -0500 Subject: [PATCH 02/17] fix(nas-backup-ctl): stop reporting an unreachable NAS as a running backup Any nonzero ssh exit fell through to "a backup or restore is running", so a dead NAS (ssh 255) looked like an active job. Treat rc >= 2 as a transport failure and die loudly with the ssh exit code and output; only the remote command's own exit 1 means a job is running. --- bin/nas-backup-ctl | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/nas-backup-ctl b/bin/nas-backup-ctl index 4331fb8..1f96f05 100755 --- a/bin/nas-backup-ctl +++ b/bin/nas-backup-ctl @@ -87,8 +87,12 @@ cmd_status() { } cmd_running() { - "${NAS[@]}" "sudo $SYNOBACKUP --is-backup-restore-running" - local rc=$? + # ssh exits 255 when the NAS is unreachable and >=2 covers other transports + # level trouble; only the remote command's own exit 1 means "running". + local out rc + out=$("${NAS[@]}" "sudo $SYNOBACKUP --is-backup-restore-running" 2>&1) + rc=$? + [ "$rc" -ge 2 ] && die "cannot reach NAS (ssh exit $rc): $out" [ "$rc" = 0 ] && echo "idle" || echo "a backup or restore is running" return "$rc" } From 834203b556c25158c7be2983d625c97f4b2d6285 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:13:05 -0500 Subject: [PATCH 03/17] fix(nas-backup-ctl): check cancel's exit status before re-reporting state cmd_cancel ignored the ssh exit status, so a failed cancel ran straight into cmd_running and misreported whatever it saw as a completed cancel. Die on a nonzero exit instead. --- bin/nas-backup-ctl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/nas-backup-ctl b/bin/nas-backup-ctl index 1f96f05..aa1957a 100755 --- a/bin/nas-backup-ctl +++ b/bin/nas-backup-ctl @@ -124,6 +124,10 @@ cmd_start() { cmd_cancel() { [ "${1:-}" = "--yes" ] || die "cancel needs --yes; it stops whatever backup is running, not a task you name" "${NAS[@]}" "sudo $SYNOBACKUP --cancel-backup" + local rc=$? + # Without this, a failed cancel gets followed by a cmd_running that reports + # the job as still going — indistinguishable from a cancel the NAS ignored. + [ "$rc" -eq 0 ] || die "cancel failed (ssh exit $rc)" sleep 5 cmd_running } From 726f915fe87d60f9f79e4e5055e1de27f5e471ab Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:13:22 -0500 Subject: [PATCH 04/17] fix(pi): route plain-OpenAI web_search auth to the standard Responses endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveAuth falls back to an "openai" provider key, but the fetch still POSTed everything to the Codex-only chatgpt.com endpoint, which rejects plain API keys. Send non-codex auth to api.openai.com/v1/responses — the body shape is identical. --- .pi-agent/extensions/web.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.pi-agent/extensions/web.ts b/.pi-agent/extensions/web.ts index 7bc2783..1735852 100644 --- a/.pi-agent/extensions/web.ts +++ b/.pi-agent/extensions/web.ts @@ -3,6 +3,9 @@ import { Type } from "typebox"; import { spawn } from "node:child_process"; const CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"; +// Plain OpenAI API keys speak the same Responses API shape, just at the +// standard endpoint — the Codex one rejects them with a 401. +const OPENAI_URL = "https://api.openai.com/v1/responses"; const SEARCH_TIMEOUT_MS = 120_000; const FETCH_TIMEOUT_MS = 45_000; const DEFAULT_MAX_CHARS = 20_000; @@ -147,7 +150,7 @@ async function search(query: string, opts: SearchOptions, ctx: ExtensionContext, headers.originator = "pi"; } - const res = await fetch(CODEX_URL, { + const res = await fetch(auth.codex ? CODEX_URL : OPENAI_URL, { method: "POST", headers, body: JSON.stringify({ From 2aa80c7c9477caccf5feaa7db599107caa233ebd Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:14:39 -0500 Subject: [PATCH 05/17] fix(pi): log usage-extension fetch failures instead of swallowing them Both window-usage extensions caught every fetch error silently, so a dead token or an API change just made the status vanish. Log the first failure of a streak (and the recovery) to console.error with an extension-name prefix; stale generations still stay quiet. --- .pi-agent/extensions/codex-window-usage.ts | 15 ++++++++++++++- .pi-agent/extensions/copilot-window-usage.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.pi-agent/extensions/codex-window-usage.ts b/.pi-agent/extensions/codex-window-usage.ts index bc7afc3..9d446c7 100644 --- a/.pi-agent/extensions/codex-window-usage.ts +++ b/.pi-agent/extensions/codex-window-usage.ts @@ -25,6 +25,9 @@ interface UsageResponse { let requestGeneration = 0; let lastValue: string | undefined; +// Log only the first failure of a streak (and the recovery) so a dead token +// doesn't spam the console on every settle. +let warned = false; function isCodex(ctx: ExtensionContext): boolean { return ctx.model?.provider === PROVIDER; @@ -183,12 +186,22 @@ async function refresh(ctx: ExtensionContext): Promise { const value = await fetchValue(ctx); if (generation !== requestGeneration || !isCodex(ctx)) return undefined; lastValue = value; + if (warned) { + warned = false; + console.error("[codex-window-usage] usage fetch recovered"); + } setStatus(ctx, value); return value; - } catch { + } catch (err) { if (generation === requestGeneration && !lastValue) { setStatus(ctx, undefined); } + if (!warned && generation === requestGeneration) { + warned = true; + console.error( + `[codex-window-usage] usage fetch failed: ${err instanceof Error ? err.message : err}`, + ); + } return undefined; } } diff --git a/.pi-agent/extensions/copilot-window-usage.ts b/.pi-agent/extensions/copilot-window-usage.ts index 9562078..19e9760 100644 --- a/.pi-agent/extensions/copilot-window-usage.ts +++ b/.pi-agent/extensions/copilot-window-usage.ts @@ -33,6 +33,9 @@ interface StoredCopilotCredential { let requestGeneration = 0; let lastValue: string | undefined; +// Log only the first failure of a streak (and the recovery) so a dead token +// doesn't spam the console on every settle. +let warned = false; function isCopilot(ctx: ExtensionContext): boolean { return ctx.model?.provider === PROVIDER; @@ -222,12 +225,22 @@ async function refresh(ctx: ExtensionContext): Promise { const value = await fetchValue(ctx); if (generation !== requestGeneration || !isCopilot(ctx)) return undefined; lastValue = value; + if (warned) { + warned = false; + console.error("[copilot-window-usage] quota fetch recovered"); + } setStatus(ctx, value); return value; - } catch { + } catch (err) { if (generation === requestGeneration && !lastValue) { setStatus(ctx, undefined); } + if (!warned && generation === requestGeneration) { + warned = true; + console.error( + `[copilot-window-usage] quota fetch failed: ${err instanceof Error ? err.message : err}`, + ); + } return undefined; } } From deda0a8a22fd92292bdc1ddf194d8955ba3e451b Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:14:58 -0500 Subject: [PATCH 06/17] fix(pi): only treat a missing Copilot auth.json as "not configured" readStoredCredential swallowed every read/parse error, so a corrupt auth.json looked identical to no credential at all. Narrow the catch to ENOENT and let anything else propagate to the fetch-failure logging. --- .pi-agent/extensions/copilot-window-usage.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.pi-agent/extensions/copilot-window-usage.ts b/.pi-agent/extensions/copilot-window-usage.ts index 19e9760..e2a50af 100644 --- a/.pi-agent/extensions/copilot-window-usage.ts +++ b/.pi-agent/extensions/copilot-window-usage.ts @@ -57,7 +57,11 @@ async function readStoredCredential(): Promise Date: Sat, 22 Aug 2026 14:15:28 -0500 Subject: [PATCH 07/17] fix(appearance-push): record ssh failures in a cache log instead of dropping them Failures were fully discarded, so an offline coop silently kept its old theme with no trace anywhere. Append timestamped failures (with the ssh exit code) to ~/.cache/appearance-push.log, trimmed to ~50 lines; stdout/stderr stay suppressed. --- bin/appearance-push | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/bin/appearance-push b/bin/appearance-push index 4528f12..b95ec08 100755 --- a/bin/appearance-push +++ b/bin/appearance-push @@ -29,7 +29,19 @@ mode=${1:-$(~/bin/appearance)} # be told about someone else's. HOSTS=(coop) -for host in "${HOSTS[@]}"; do +# stdout/stderr stay suppressed (a failed push must not interrupt the toggle), +# but failures used to vanish entirely; append them to a small cache log. +LOG="$HOME/.cache/appearance-push.log" +record_failure() { + { + printf '%s push to %s failed (ssh exit %s)\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$1" "$2" + # Keep the last ~50 lines so an offline box can't grow it unbounded. + tail -n 49 "$LOG" 2>/dev/null + } > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" +} + +push() { + local host=$1 rc=0 # BatchMode and the timeout so a box that is off or off-network costs three # seconds in the background rather than a prompt or a hang. ClearAllForwardings # keeps a push from re-declaring the tunnels a Host block asks for. @@ -38,6 +50,11 @@ for host in "${HOSTS[@]}"; do -o ClearAllForwardings=yes \ "$host" \ 'mkdir -p ~/.cache && cat > ~/.cache/dark-mode.$$ && - mv ~/.cache/dark-mode.$$ ~/.cache/dark-mode' >/dev/null 2>&1 & + mv ~/.cache/dark-mode.$$ ~/.cache/dark-mode' >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 0 ] || record_failure "$host" "$rc" +} + +for host in "${HOSTS[@]}"; do + push "$host" & done wait From 87bb8458b45955938832361da7afa7377e0d4ed4 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:15:46 -0500 Subject: [PATCH 08/17] fix(tmux): warn on stderr when btop.sh cannot rewrite color_theme The sed-failure branch cleaned up the temp file and said nothing, leaving btop silently on a stale theme. --- .tmux/btop.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.tmux/btop.sh b/.tmux/btop.sh index 16280ba..5ff3f5e 100755 --- a/.tmux/btop.sh +++ b/.tmux/btop.sh @@ -23,6 +23,7 @@ if [ -f "$conf" ]; then mv "$tmp" "$conf" else rm -f "$tmp" + echo "btop.sh: could not update color_theme" >&2 fi fi From 66bf89f5ddfac5270c0d8722d245a63c55293b77 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:16:22 -0500 Subject: [PATCH 09/17] fix(pi): flag the plain-text fallback when pandoc is unavailable in web_fetch The catch fell back to tag-stripped text with no indication that formatting was lost. Prepend a notice so the degraded output is recognizable. --- .pi-agent/extensions/web.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.pi-agent/extensions/web.ts b/.pi-agent/extensions/web.ts index 1735852..87358ec 100644 --- a/.pi-agent/extensions/web.ts +++ b/.pi-agent/extensions/web.ts @@ -333,7 +333,9 @@ async function toMarkdown(html: string, signal?: AbortSignal): Promise { ); return md.replace(/\n{3,}/g, "\n\n").replace(/^:::.*$/gm, "").trim(); } catch { - return textFallback(html); + // Say so when the fallback engages — unformatted text after a pandoc + // regression should not look like the page was always like this. + return `[web_fetch: pandoc unavailable/failed — unformatted text]\n\n${textFallback(html)}`; } } From b7ce80cc9ae90632fd227118245b9b24a0ec4e85 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:16:40 -0500 Subject: [PATCH 10/17] docs(pi): reconcile pi-ext-check header with the new .pi-agent/package.json The header claimed we avoid adding a package.json to this repo, but one now exists (marking .pi-agent ESM). Reword to describe its actual role: type marker only, no pinned dependencies. --- bin/pi-ext-check | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/pi-ext-check b/bin/pi-ext-check index bb2ac1b..f8c86eb 100755 --- a/bin/pi-ext-check +++ b/bin/pi-ext-check @@ -4,9 +4,11 @@ # Usage: pi-ext-check [-h|--help] [--typecheck-only|--test-only] # # pi loads these extensions from the globally installed @earendil-works/pi-coding-agent, -# so this borrows that install's types rather than adding a package.json to the -# dotfiles repo: .pi-agent/node_modules/ gets symlinks to the global pi, pi-tui, -# typebox and @types/node, which is enough for both tsc and Node's ESM resolver. +# so this borrows that install's types rather than pinning any dependency +# versions here: .pi-agent/package.json exists only to mark the directory ESM +# for tsc and Node, and .pi-agent/node_modules/ gets symlinks to the global pi, +# pi-tui, typebox and @types/node, which is enough for both tsc and Node's ESM +# resolver. set -euo pipefail From 220f700667db062ce4895f6b26e9fc69a32c11dd Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:16:53 -0500 Subject: [PATCH 11/17] docs: use the measured ~115ms bundle speedup everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README quotes the measured figure (716→602ms on macOS, 738→616ms on Linux) but bin/pi-bundle and mise.toml still said ~150ms. --- bin/pi-bundle | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/pi-bundle b/bin/pi-bundle index c6294a0..9a899c8 100755 --- a/bin/pi-bundle +++ b/bin/pi-bundle @@ -4,7 +4,7 @@ # Usage: pi-bundle [-h|--help] [--status|--off] # # pi ships its Node build as ~200 ES modules, and 177 of them load before the -# first frame. Collapsing them into one file takes ~150ms off every launch. +# first frame. Collapsing them into one file takes ~115ms off every launch. # # Two patches are needed, and they only pay off together: # diff --git a/mise.toml b/mise.toml index c6c1dfc..a830518 100644 --- a/mise.toml +++ b/mise.toml @@ -734,7 +734,7 @@ pi --version # pi's Node build loads 177 ES modules before it paints the first frame. # bin/pi-bundle collapses them into one file and repoints the `pi` bin at -# bin/pi-launch, which is worth ~150ms per launch. Strictly an optimization: +# bin/pi-launch, which is worth ~115ms per launch. Strictly an optimization: # the launcher falls back to dist/cli.js whenever the bundle is missing or # older than the package, so a failure here costs speed and nothing else. pi_bundle="${MISE_PROJECT_ROOT:-.}/bin/pi-bundle" From 8d9a1de5c39e5faaf5b15bc3520ea5e84eac52e8 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:17:20 -0500 Subject: [PATCH 12/17] docs(pi): correct footer.ts header sketch and theme-usage claim The sketch predated the boot timer and bypass marker, and still showed numbered ahead/behind icons the p10k-lean format never renders. Also fix "only the extension-status row uses the theme": the dim theme foreground is shared by the boot timer and peer session name. --- .pi-agent/extensions/footer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.pi-agent/extensions/footer.ts b/.pi-agent/extensions/footer.ts index ce167c2..6cb6829 100644 --- a/.pi-agent/extensions/footer.ts +++ b/.pi-agent/extensions/footer.ts @@ -2,15 +2,19 @@ * Minimal footer/statusline for pi — a lean replacement for the pi-footer package. * * Renders one line: - * dir provider model thinking branch +s ±u ?n ctx/window $cost [inline statuses] session-name + * dir provider model thinking branch* ⇣⇡ ctx/window $cost [inline statuses] ⚡boot bypass session-name * plus an optional dim row of other extension statuses (from ctx.ui.setStatus). + * The boot timer shows until the first message; "bypass" replaces it as the + * right-most main-line marker while Approval Guardian is bypassed. * * Design notes: * - No config UI, no widget registry: the layout is this file. * - Git state comes from a single `git status --porcelain=v1` per refresh, cached * with a 5s TTL and refreshed asynchronously (stale-while-revalidate), so a slow * repo never blocks a render. - * - Colors are plain ANSI-16 SGR codes; only the extension-status row uses the theme. + * - Colors are plain ANSI-16 SGR codes; the theme supplies only the dim + * foreground, shared by the boot timer, the peer session name, and the + * extension-status row. */ import { appendFile, readFile, realpath, stat, writeFile } from "node:fs/promises"; From 57056cdd60cacf7449e5bac5c34f0dde76088750 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:24:46 -0500 Subject: [PATCH 13/17] fix(pi): propagate aborts through web_fetch's pandoc fallback and name the failure The bare catch turned a user cancel or fetch timeout (AbortError) into degraded unformatted output instead of propagating the cancellation, and the banner never said why pandoc was skipped. Rethrow aborts; include the error message in the fallback banner. --- .pi-agent/extensions/web.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.pi-agent/extensions/web.ts b/.pi-agent/extensions/web.ts index 87358ec..1d50cad 100644 --- a/.pi-agent/extensions/web.ts +++ b/.pi-agent/extensions/web.ts @@ -332,10 +332,11 @@ async function toMarkdown(html: string, signal?: AbortSignal): Promise { signal, ); return md.replace(/\n{3,}/g, "\n\n").replace(/^:::.*$/gm, "").trim(); - } catch { - // Say so when the fallback engages — unformatted text after a pandoc - // regression should not look like the page was always like this. - return `[web_fetch: pandoc unavailable/failed — unformatted text]\n\n${textFallback(html)}`; + } catch (err) { + // A cancel or timeout is not a pandoc failure; let it propagate. + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + const reason = err instanceof Error ? err.message : String(err); + return `[web_fetch: pandoc failed (${reason}) — unformatted text]\n\n${textFallback(html)}`; } } From 1dc98b9bd469987c03e3b3867ab391dcfe44e35b Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:25:10 -0500 Subject: [PATCH 14/17] fix(nas-backup-ctl): tell a running backup apart from a failed remote sudo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit 1 from the is-running probe was read as "a backup or restore is running", but sudo also exits 1 when the remote command fails — and cmd_cancel's safety re-check depends on that answer being truthful. A genuine answer is silent (the idle case prints nothing either), so exit 1 with output now dies loudly; only silent exit 1 counts as running. --- bin/nas-backup-ctl | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bin/nas-backup-ctl b/bin/nas-backup-ctl index aa1957a..c41b4d5 100755 --- a/bin/nas-backup-ctl +++ b/bin/nas-backup-ctl @@ -87,14 +87,23 @@ cmd_status() { } cmd_running() { - # ssh exits 255 when the NAS is unreachable and >=2 covers other transports + # ssh exits 255 when the NAS is unreachable and >=2 covers other transport- # level trouble; only the remote command's own exit 1 means "running". local out rc out=$("${NAS[@]}" "sudo $SYNOBACKUP --is-backup-restore-running" 2>&1) rc=$? [ "$rc" -ge 2 ] && die "cannot reach NAS (ssh exit $rc): $out" - [ "$rc" = 0 ] && echo "idle" || echo "a backup or restore is running" - return "$rc" + # Exit 1 is ambiguous: synobackup uses it for "is running", but sudo uses it + # too when the remote command fails. A genuine answer prints nothing (the + # idle case is silent as well), so any output alongside exit 1 means the + # remote command blew up rather than a job being active. + if [ "$rc" = 1 ]; then + [ -z "$out" ] || die "unexpected failure on NAS (exit 1): $out" + echo "a backup or restore is running" + return 1 + fi + [ "$rc" = 0 ] || die "unexpected exit $rc from NAS: $out" + echo "idle" } cmd_log() { From 4e9d11fae4fcd3967fffb539f30a3d555f8b8216 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:25:25 -0500 Subject: [PATCH 15/17] fix(nas-backup-ctl): exit 2 from die so check failures don't masquerade as "running" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit die exited 1, the same code the running subcommand uses to report an active job — a scripted caller couldn't tell a failed probe from a backup in progress. die now exits 2 and the usage text documents the running subcommand's full exit-code contract. --- bin/nas-backup-ctl | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/nas-backup-ctl b/bin/nas-backup-ctl index c41b4d5..651c0e1 100755 --- a/bin/nas-backup-ctl +++ b/bin/nas-backup-ctl @@ -20,14 +20,18 @@ SYNOBACKUP=/usr/syno/bin/synobackup # Use the hostname for the Pi, never the bare IP: only nas-backup.local is in # known_hosts, and the box is dual-homed (wlan0 sits on the IoT SSID). -die() { printf 'nas-backup-ctl: %s\n' "$*" >&2; exit 1; } +# Exit 2 rather than 1: "running" uses 1, and scripted callers of the running +# subcommand must be able to tell job state from a failed check. +die() { printf 'nas-backup-ctl: %s\n' "$*" >&2; exit 2; } usage() { cat <<'EOF' usage: nas-backup-ctl status sizes, free space, worker state, throughput hint - running exit 0 if idle, 1 if a backup/restore is running + running exit 0 if idle, 1 if a backup/restore is running; + exit 2 if the check itself failed (NAS unreachable, + remote command error) log [n] last n lines of the synobackupd job log (default 15) start start task 1, 2, or 4 cancel --yes cancel the RUNNING backup (see warning below) From 8b92e46f8fff047f9098e32957c66d22b8ad25a4 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:27:55 -0500 Subject: [PATCH 16/17] fix(appearance-push): make failure logging survive a missing cache dir and record ssh's reason Three problems in the new logging: tail of a not-yet-existing log made the write group exit nonzero on the very first failure, so the log was never created; a missing ~/.cache failed the redirect silently; and racing toggles shared one tmp file name. mkdir -p the log dir, suffix the tmp file with $$, warn loudly if recording itself fails, and fold ssh's last stderr line into the record so a 255-auth vs 255-timeout is distinguishable. --- bin/appearance-push | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/bin/appearance-push b/bin/appearance-push index b95ec08..006cf3f 100755 --- a/bin/appearance-push +++ b/bin/appearance-push @@ -33,25 +33,37 @@ HOSTS=(coop) # but failures used to vanish entirely; append them to a small cache log. LOG="$HOME/.cache/appearance-push.log" record_failure() { + local host=$1 rc=$2 err=$3 + mkdir -p "${LOG%/*}" || { + echo "appearance-push: cannot create ${LOG%/*}; push to $host failed (ssh exit $rc)" >&2 + return + } + # $$ in the tmp name: two toggles racing each other must not share a file. { - printf '%s push to %s failed (ssh exit %s)\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$1" "$2" + printf '%s push to %s failed (ssh exit %s)%s\n' \ + "$(date '+%Y-%m-%dT%H:%M:%S')" "$host" "$rc" "${err:+: $err}" # Keep the last ~50 lines so an offline box can't grow it unbounded. - tail -n 49 "$LOG" 2>/dev/null - } > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" + tail -n 49 "$LOG" 2>/dev/null || : + } > "$LOG.$$.tmp" && mv "$LOG.$$.tmp" "$LOG" || + echo "appearance-push: could not write $LOG; push to $host failed (ssh exit $rc)" >&2 } push() { - local host=$1 rc=0 + local host=$1 rc=0 err # BatchMode and the timeout so a box that is off or off-network costs three # seconds in the background rather than a prompt or a hang. ClearAllForwardings # keeps a push from re-declaring the tunnels a Host block asks for. - printf '%s\n' "$mode" | ssh -o BatchMode=yes \ + err=$(printf '%s\n' "$mode" | ssh -o BatchMode=yes \ -o ConnectTimeout=3 \ -o ClearAllForwardings=yes \ "$host" \ 'mkdir -p ~/.cache && cat > ~/.cache/dark-mode.$$ && - mv ~/.cache/dark-mode.$$ ~/.cache/dark-mode' >/dev/null 2>&1 || rc=$? - [ "$rc" -eq 0 ] || record_failure "$host" "$rc" + mv ~/.cache/dark-mode.$$ ~/.cache/dark-mode' 2>&1 >/dev/null) || rc=$? + if [ "$rc" -ne 0 ]; then + # Only the last stderr line fits the record; it separates an auth refusal + # from a timeout on the otherwise identical "exit 255". + record_failure "$host" "$rc" "${err##*$'\n'}" + fi } for host in "${HOSTS[@]}"; do From 83f61653bd28242cd2daa62ea604fd7f69348888 Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Sat, 22 Aug 2026 14:28:16 -0500 Subject: [PATCH 17/17] fix(tmux): bail out cleanly when pane-find's mktemp fails An unset PANEFIND_DIR would turn the EXIT trap into rm -rf with an empty argument and the next line would write to /mode. Die with a message instead. --- .tmux/pane-find.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.tmux/pane-find.sh b/.tmux/pane-find.sh index b2aafcc..73e93a7 100755 --- a/.tmux/pane-find.sh +++ b/.tmux/pane-find.sh @@ -162,7 +162,10 @@ case ${1-} in esac # A fixed template without XXXX fails on GNU mktemp, so spell the suffix out. -PANEFIND_DIR=$(mktemp -d "${TMPDIR:-/tmp}/panefind.XXXXXX") +PANEFIND_DIR=$(mktemp -d "${TMPDIR:-/tmp}/panefind.XXXXXX") || { + echo "pane-find: mktemp failed" >&2 + exit 1 +} export PANEFIND_DIR trap 'rm -rf "$PANEFIND_DIR"' EXIT echo title > "$PANEFIND_DIR/mode"