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..e2a50af 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; @@ -54,7 +57,11 @@ async function readStoredCredential(): 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; } } 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"; diff --git a/.pi-agent/extensions/web.ts b/.pi-agent/extensions/web.ts index 7bc2783..1d50cad 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({ @@ -329,8 +332,11 @@ async function toMarkdown(html: string, signal?: AbortSignal): Promise { signal, ); return md.replace(/\n{3,}/g, "\n\n").replace(/^:::.*$/gm, "").trim(); - } catch { - return 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)}`; } } 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 diff --git a/.tmux/pane-find.sh b/.tmux/pane-find.sh index 0b93e9b..73e93a7 100755 --- a/.tmux/pane-find.sh +++ b/.tmux/pane-find.sh @@ -161,7 +161,11 @@ 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") || { + echo "pane-find: mktemp failed" >&2 + exit 1 +} export PANEFIND_DIR trap 'rm -rf "$PANEFIND_DIR"' EXIT echo title > "$PANEFIND_DIR/mode" diff --git a/bin/appearance-push b/bin/appearance-push index 4528f12..006cf3f 100755 --- a/bin/appearance-push +++ b/bin/appearance-push @@ -29,15 +29,44 @@ 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() { + 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)%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" || + echo "appearance-push: could not write $LOG; push to $host failed (ssh exit $rc)" >&2 +} + +push() { + 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 & + 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 + push "$host" & done wait diff --git a/bin/nas-backup-ctl b/bin/nas-backup-ctl index 4331fb8..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) @@ -87,10 +91,23 @@ cmd_status() { } cmd_running() { - "${NAS[@]}" "sudo $SYNOBACKUP --is-backup-restore-running" - local rc=$? - [ "$rc" = 0 ] && echo "idle" || echo "a backup or restore is running" - return "$rc" + # 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" + # 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() { @@ -120,6 +137,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 } 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/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 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"