Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dcffe3a
fix(tmux): give pane-find's mktemp template Xs so it works on GNU cor…
ericboehs Aug 22, 2026
bf06812
fix(nas-backup-ctl): stop reporting an unreachable NAS as a running b…
ericboehs Aug 22, 2026
834203b
fix(nas-backup-ctl): check cancel's exit status before re-reporting s…
ericboehs Aug 22, 2026
726f915
fix(pi): route plain-OpenAI web_search auth to the standard Responses…
ericboehs Aug 22, 2026
2aa80c7
fix(pi): log usage-extension fetch failures instead of swallowing them
ericboehs Aug 22, 2026
deda0a8
fix(pi): only treat a missing Copilot auth.json as "not configured"
ericboehs Aug 22, 2026
b8a65d8
fix(appearance-push): record ssh failures in a cache log instead of d…
ericboehs Aug 22, 2026
87bb845
fix(tmux): warn on stderr when btop.sh cannot rewrite color_theme
ericboehs Aug 22, 2026
66bf89f
fix(pi): flag the plain-text fallback when pandoc is unavailable in w…
ericboehs Aug 22, 2026
b7ce80c
docs(pi): reconcile pi-ext-check header with the new .pi-agent/packag…
ericboehs Aug 22, 2026
220f700
docs: use the measured ~115ms bundle speedup everywhere
ericboehs Aug 22, 2026
8d9a1de
docs(pi): correct footer.ts header sketch and theme-usage claim
ericboehs Aug 22, 2026
57056cd
fix(pi): propagate aborts through web_fetch's pandoc fallback and nam…
ericboehs Aug 22, 2026
1dc98b9
fix(nas-backup-ctl): tell a running backup apart from a failed remote…
ericboehs Aug 22, 2026
4e9d11f
fix(nas-backup-ctl): exit 2 from die so check failures don't masquera…
ericboehs Aug 22, 2026
8b92e46
fix(appearance-push): make failure logging survive a missing cache di…
ericboehs Aug 22, 2026
83f6165
fix(tmux): bail out cleanly when pane-find's mktemp fails
ericboehs Aug 22, 2026
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
15 changes: 14 additions & 1 deletion .pi-agent/extensions/codex-window-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -183,12 +186,22 @@ async function refresh(ctx: ExtensionContext): Promise<string | undefined> {
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;
}
}
Expand Down
21 changes: 19 additions & 2 deletions .pi-agent/extensions/copilot-window-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -54,7 +57,11 @@ async function readStoredCredential(): Promise<StoredCopilotCredential | undefin
return credential && typeof credential === "object"
? (credential as StoredCopilotCredential)
: undefined;
} catch {
} catch (err) {
// A missing auth.json just means Copilot isn't configured. Anything else
// (corrupt JSON, bad permissions) should surface through fetchValue's
// error path instead of masquerading as "not configured".
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
return undefined;
}
}
Expand Down Expand Up @@ -222,12 +229,22 @@ async function refresh(ctx: ExtensionContext): Promise<string | undefined> {
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;
}
}
Expand Down
8 changes: 6 additions & 2 deletions .pi-agent/extensions/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
12 changes: 9 additions & 3 deletions .pi-agent/extensions/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -329,8 +332,11 @@ async function toMarkdown(html: string, signal?: AbortSignal): Promise<string> {
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)}`;
}
}

Expand Down
1 change: 1 addition & 0 deletions .tmux/btop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion .tmux/pane-find.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 32 additions & 3 deletions bin/appearance-push
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 27 additions & 6 deletions bin/nas-backup-ctl
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>

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 <id> start task 1, 2, or 4
cancel --yes cancel the RUNNING backup (see warning below)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion bin/pi-bundle
Original file line number Diff line number Diff line change
Expand Up @@ -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:
#
Expand Down
8 changes: 5 additions & 3 deletions bin/pi-ext-check
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down