Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions bstack/MANIFEST
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ commands/how.md
commands/why.md
commands/unslop.md
commands/pr-review.md
pi/extensions/fallback.ts
pi/instructions.md
pi/agents/bstack-builder.md
pi/agents/bstack-judge.md
Expand Down
114 changes: 114 additions & 0 deletions bstack/pi/extensions/fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isContextOverflow, type AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

const profile = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const efforts = /[#:]((?:off|minimal|low|medium|high|xhigh|max))$/;

function read(name: string): string | undefined {
try {
return readFileSync(resolve(profile, name), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}

export default function (pi: ExtensionAPI) {
const attempted = new Set<string>();
let response: { status: number; rejected: boolean } | undefined;
let assistant: AssistantMessage | undefined;

const noteSession = (model: string) => {
if (!process.env.HC_TERMINAL_ID) return;
const current = pi.getSessionName();
const note = current ? `${current.replace(/ · fallback .*$/, "")} · fallback ${model.split("/")[0]}` : `fallback: ${model}`;
pi.setSessionName(note);
};

pi.on("message_start", (event, ctx) => {
if (event.message.role !== "user") return;
attempted.clear();
ctx.ui.setStatus("bercail-fallback", undefined);
});
pi.on("turn_start", () => {
response = undefined;
assistant = undefined;
});
pi.on("after_provider_response", (event) => {
response = {
status: event.status,
rejected: event.headers["anthropic-ratelimit-unified-status"] === "rejected",
};
});
pi.on("turn_end", (event) => {
assistant = event.message.role === "assistant" ? event.message : undefined;
});

pi.on("agent_before_settle", async (event, ctx) => {
if (event.outcome !== "error" || assistant?.stopReason !== "error" || ctx.signal?.aborted) return;
const error = assistant.errorMessage ?? "";
if (response && [400, 401, 403].includes(response.status)) return;
if (isContextOverflow(assistant, ctx.model?.contextWindow)) return;
// Explicit request errors win over transport signals, including bridge headers.
if (/\b(?:400|401|403)\b|unauthenticated|unauthorized|not.authorized|authenticat|invalid.api.key|permission.denied|access.denied|forbidden|invalid_request_error|bad.request|context.{0,30}(?:overflow|length|window|exceed|limit)|prompt.{0,20}(?:too.long|exceed)|too.many.tokens|request.{0,10}abort/i.test(error)) return;
if (!(response?.status === 429 || response?.rejected || /\b429\b|rate[_ -]?limit|usage[_ -]?limit|usage cap|quota (?:exceeded|exhausted)|hit your limit|out of (?:usage|credits)/i.test(error))) return;

const current = `${assistant.provider}/${assistant.model}`;
attempted.add(current);
const models = new Map((read("models") ?? "").split(/\r?\n/).flatMap<[string, string]>((line) => {
const index = line.indexOf("=");
return index > 0 ? [[line.slice(0, index).trim(), line.slice(index + 1).trim()]] : [];
}));
const order = (read("fallback") ?? "claude\ngpt\ngrok").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const skipped: string[] = [];
for (const family of order) {
const configured = models.get(family);
if (!configured) { skipped.push(`${family}: no model`); continue; }
const effort = configured.match(efforts)?.[1];
const name = configured.replace(efforts, "");
if (attempted.has(name)) continue;
attempted.add(name);
const slash = name.indexOf("/");
const model = slash > 0 ? ctx.modelRegistry.find(name.slice(0, slash), name.slice(slash + 1)) : undefined;
if (!model) { skipped.push(`${name}: unavailable`); continue; }
if (model.provider === "claude-bridge") {
// The bridge's dummy API key makes setModel succeed even without a Claude login.
try {
const auth = await pi.exec("env", ["-u", "ANTHROPIC_API_KEY", "-u", "ANTHROPIC_BASE_URL", "-u", "ANTHROPIC_AUTH_TOKEN", "claude", "auth", "status", "--json"], { signal: ctx.signal, timeout: 5000 });
const login = auth.code === 0 ? JSON.parse(auth.stdout) : undefined;
if (login?.loggedIn !== true || !["claude.ai", "oauth_token"].includes(login.authMethod)) {
skipped.push(`${name}: no Claude subscription login`);
continue;
}
} catch {
if (ctx.signal?.aborted) return;
skipped.push(`${name}: Claude login unavailable`);
continue;
}
}
if (ctx.signal?.aborted) return;
if (!(await pi.setModel(model))) { skipped.push(`${name}: no login`); continue; }
if (effort) pi.setThinkingLevel(effort as Parameters<typeof pi.setThinkingLevel>[0]);
const text = `Rate limit: switched ${current} → ${name}. Continuing the same request.`;
ctx.ui.setStatus("bercail-fallback", `fallback: ${name}`);
ctx.ui.notify(text, "warning");
noteSession(name);
const failed = [...event.context.contextEntries].reverse().find((entry) => entry.messages.some((message) => message.role === "assistant" && message.stopReason === "error") && entry.sourceEntry.type === "message");
// Omit the empty error assistant. A later provider 400s on it and then looks like a non-limit failure.
return {
entries: [...event.entries,
...(failed ? [{ type: "context_edit" as const, targetId: failed.sourceEntry.id, replacement: null }] : []),
{ type: "custom_message" as const, customType: "bercail-fallback", content: text, display: true, details: { from: current, to: name, skipped } },
],
continue: true,
};
}
const text = `Rate limit: fallback exhausted (${order.join(" → ") || "no candidates"}). All configured models are limited or unavailable.${skipped.length ? ` ${skipped.join("; ")}.` : ""}`;
ctx.ui.setStatus("bercail-fallback", "fallback exhausted");
ctx.ui.notify(text, "error");
return { entries: [...event.entries, { type: "custom" as const, customType: "bercail-fallback-error", data: { text, attempted: [...attempted], skipped } }], continue: false };
});
}
12 changes: 9 additions & 3 deletions config/agentic-dev/config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ review = "tuicr"
auto_review = true

[bstack]
# opencode with your own Claude, ChatGPT, and X subscriptions. New installs turn
# it on; `bercail bstack setup` connects plans.
# Pi with your own Claude, ChatGPT, and X subscriptions. Setup installs the
# Claude bridge. Sign in with `claude auth login`, /login openai, /login xai.
enabled = true
# Existing configs without this key keep opencode.
harness = "pi"
# Pi rate-limit fallback candidates, scanned from the start, skipping the current
# and already attempted models. Only claude/gpt/grok, no duplicates. [] disables it.
fallback = ["claude", "gpt", "grok"]
# A model per provider. Empty: auto, the newest model of each connected plan
# (re-picked on bercail update). Set an opencode id (#variant allowed) to pin one.
# (re-picked on bercail update). Set a provider/model id (#variant allowed) to pin one.
# Claude on Pi: claude-bridge/claude-opus-5-5, not anthropic/claude-opus-5-5.
claude = ""
gpt = ""
grok = ""
Expand Down
6 changes: 4 additions & 2 deletions docs/bstack.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ dev # the desk starts opencode as the bstack agent

## On Pi

New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail update` to render the [Pi](https://pi.dev) profile with your own logins (`/login anthropic`, `/login openai`, `/login xai` inside `pi`). Pi's Anthropic login bills extra usage per token. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. Automatic Pi desk launching is a separate follow-up.
New configs set `harness = "pi"` under `[bstack]`. Set it in an existing `config.toml` and run `bercail bstack setup` to install [Pi](https://pi.dev) (>=1.2.0) and `npm:pi-claude-bridge@0.11.0`. Claude uses your Claude Code login (`claude auth login`), not Pi's `/login anthropic` or per-token Extra Usage. GPT and Grok use `/login openai` and `/login xai` inside Pi. Existing configs without `harness` keep using opencode; `harness = "opencode"` selects it explicitly. Automatic Pi desk launching is a separate follow-up.

bercail renders the profile to `~/.local/share/bercail/pi/` and never edits `~/.pi/agent/`; each launch names the profile with flags. No bercail extensions are installed or loaded. Role boundaries and git rules are instructions, not enforced tool restrictions. The builder, judge, council, and autopilot run as interactive `hc` sibling agents from templates in `~/.config/herdr-core/templates/`. Pi handoffs use desks or interactive siblings, not one-shot runs.
bercail renders the profile to `~/.local/share/bercail/pi/`. Installing the bridge adds its pinned package to Pi's settings, leaving unrelated settings and auth intact. Every role explicitly loads the bridge and the managed `extensions/fallback.ts` with `-e`, and unsets `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, and `ANTHROPIC_AUTH_TOKEN` so fallback to Claude uses your plan. Role boundaries and git rules remain instructions, not enforced tool restrictions. The builder, judge, council, and autopilot run as interactive `hc` sibling agents from templates in `~/.config/herdr-core/templates/`. Pi handoffs use desks or interactive siblings, not one-shot runs.

After Pi exhausts its own rate-limit retries, the extension tries the next model in `[bstack] fallback` (default `["claude", "gpt", "grok"]`). It scans from the start, skipping the current and already attempted models, and leaves the initial role picks unchanged. Set `fallback = ["gpt", "grok"]` to limit candidates, or `[]` to disable them. Families must be unique. The profile stores resolved `family=model` lines in `pi/models` and one fallback family per line in `pi/fallback`. `bercail bstack status` reports the order, bridge availability, and Claude Code subscription auth (`authMethod` `claude.ai` or `oauth_token`, not an API key). `bercail doctor` checks the installed bridge and flags missing or stale profile data.

## Where to find what

Expand Down
17 changes: 17 additions & 0 deletions docs/bstack/08-make-it-yours.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ connect plans later ([the order](./02-the-bstack-agent.md#the-roles)). Higher
effort reasons longer and spends more of each plan; a model without the level
you ask for gets the next one down.

### Pi rate-limit fallback

On `harness = "pi"`, `fallback = ["claude", "gpt", "grok"]` sets runtime
candidates after Pi exhausts rate-limit retries. Each switch scans from the
start, skipping the current and already attempted models. For example,
`fallback = ["gpt", "grok"]` tries GPT before Grok. Only these three family
names are valid, duplicates are rejected, and `[]` disables fallback. This does
not change the initial role model picks above or opencode behavior.

Pi Claude IDs use `claude-bridge/`, for example
`claude-bridge/claude-opus-5-5#high`. The bridge uses `claude auth login`, not
Pi's Anthropic login. Status and doctor count a subscription only when
`claude auth status` reports `authMethod` `claude.ai` or `oauth_token`.
Run `bercail update` after changing fallback or model pins. `bercail bstack status`
shows the order and `bercail doctor` flags stale profile data. See
[On Pi](../bstack.md#on-pi) for installation and role launches.

## What bstack turns on in opencode

- **`bstack` as the default agent**, on the lead's model, with bercail's
Expand Down
4 changes: 3 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ auto_review = true

[bstack]
enabled = true
harness = "pi" # unset in older configs: opencode
fallback = ["claude", "gpt", "grok"] # Pi rate-limit candidates, no duplicates
claude = ""
gpt = ""
grok = ""
Expand All @@ -33,7 +35,7 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b
- worktrunk hooks (`~/.config/worktrunk/config.toml`, `herdr-layout.sh`).
- `~/.local/bin/{bercail,agentic-dev,bercail-desk-agent,bercail-desk-claude-hook}` (a mise or packslip install keeps its own `bercail`), and `~/.local/share/bercail/`.
- `~/.agents/skills/{handoff,review,bstack,council,autopilot,how,why,unslop,desk-restore}`.
- bstack's files in `~/.config/opencode` (see [Make it yours](bstack/08-make-it-yours.md#your-own-files)).
- bstack's Pi profile in `~/.local/share/bercail/pi/` and role templates in `~/.config/herdr-core/templates/`, or its files in `~/.config/opencode` (see [Make it yours](bstack/08-make-it-yours.md#your-own-files)). The Pi harness installs `npm:pi-claude-bridge@0.11.0` into Pi's package settings.
- The local TUI plugin `~/.config/opencode/plugins/bercail-desk/tui.ts`, including when bstack is off.
- `diff_watch_interval_ms` in `~/.config/tuicr/config.toml`, unless you set it already.
- On Linux and Omarchy, the extras in [platforms](development.md#platforms).
Expand Down
Loading
Loading