pi-bedrouter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Barry Melton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # pi-bedrouter
2
+
3
+ A [Pi](https://pi.dev) extension for [bedrouter](https://github.com/bmelton/bedrouter), the cost-aware model router in front of AWS Bedrock. It makes the router a first-class part of a Pi session:
4
+
5
+ - **finds bedrouter** (a configured checkout, or the npm dependency this package ships with) and installs or builds it when it is missing
6
+ - **starts the server** when it is not already running, seeding `.env` and `bedrouter.json` from bedrouter's examples on first run
7
+ - **registers a `bedrouter` provider** in Pi whose models come straight from `bedrouter.json`: the `auto` aliases (the router decides everything) plus each rung of each ladder
8
+ - **switches the session to `bedrouter/auto`** when the server is healthy, so nobody has to pick a model
9
+ - **shows what served each request in the footer**, live: routed model vs requested, class and deciding signal, the classifier's note, and the session's running cost against what the requested model would have cost
10
+ - **`/bedrouter`** for everything else: status, start/stop/restart, doctor and the per-rung entitlement probe, the savings report, the last N decisions, pi-agents fit notes
11
+
12
+ ```
13
+ ⇄ gpt-oss-120b ≠ gpt-oss-20b explore·clf:explore $0.0412 saved $0.0188 (31%) ↑1
14
+ ```
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ pi install npm:pi-bedrouter # once published
20
+ # or, from a checkout:
21
+ pi install /path/to/pi-bedrouter # or add the path to "packages" in ~/.pi/agent/settings.json
22
+ ```
23
+
24
+ The package depends on `bedrouter` (a git dependency with a build step), so `npm install` inside it produces a runnable `bedrouter` binary. If you already have a bedrouter checkout you'd rather use, point at it (below) and the dependency is ignored.
25
+
26
+ Bedrouter needs AWS credentials that can call Bedrock; see its README for the `aws sso login` recipe. pi-bedrouter does not touch credentials, it just starts the server in a directory that has a `.env`.
27
+
28
+ ## Settings
29
+
30
+ `~/.pi/agent/pi-bedrouter.json` (all optional; `/bedrouter config` creates it with defaults):
31
+
32
+ | Key | Default | Meaning |
33
+ | --- | --- | --- |
34
+ | `path` | the `bedrouter` dependency | A bedrouter checkout or install to use instead |
35
+ | `home` | `path` if set, else `~/.bedrouter` | Working directory for the server: `.env`, `bedrouter.json`, `bedrouter.log.jsonl`, `server.log` |
36
+ | `port` | `20129` | Port to run / expect the server on (`BEDROUTER_PORT` overrides) |
37
+ | `autoStart` | `true` | Start the server on session start when it is not running |
38
+ | `autoSelect` | `"auto"` | Model id to switch the session to when bedrouter is healthy (`"auto"`, `"auto-oss"`, a rung alias, or `false` to leave the model alone) |
39
+ | `debug` | `false` | Start the server with `BEDROUTER_DEBUG=1` (per-request trace in `server.log`) |
40
+ | `footer` | `true` | Show the routing status line in Pi's footer |
41
+ | `providerName` | `"bedrouter"` | Provider name registered in Pi |
42
+ | `healthPollS` | `15` | Seconds between background health checks. A dead server flips the footer to `bedrouter: DOWN` and, with `autoStart`, is restarted (at most once a minute); `0` disables the poll |
43
+
44
+ Example for a developer with a checkout:
45
+
46
+ ```json
47
+ { "path": "~/projects/ai/bedrouter", "autoSelect": "auto-oss" }
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ | Command | Does |
53
+ | --- | --- |
54
+ | `/bedrouter` or `/bedrouter status` | Install location, server health (pid, version, region, classifier), registered models, current model, last decision |
55
+ | `/bedrouter start` / `stop` / `restart` | Manage the server. It is shared by every Pi session, so `stop` affects all of them |
56
+ | `/bedrouter install` | `npm install` the dependency, or `npm run build` a checkout that has no `dist/` |
57
+ | `/bedrouter doctor` | Which credential source resolved, expiry, the loaded ladder |
58
+ | `/bedrouter probe` | One 1-token request per rung: which models this AWS account can actually invoke |
59
+ | `/bedrouter report [--since t] [--json]` | Savings report over the decision log |
60
+ | `/bedrouter log [n]` | The last n routing decisions, one line each |
61
+ | `/bedrouter models` | Re-read `bedrouter.json` and re-register the provider (after editing the ladder) |
62
+ | `/bedrouter fitnotes` | Merge model notes into `~/.pi/agent/workflows.json` so the pi-agents planner defaults to `bedrouter/auto` and only pins premium rungs for planning/review |
63
+ | `/bedrouter config` | Show (and create) the settings file |
64
+
65
+ ## How the footer works
66
+
67
+ Bedrouter echoes every routing decision in response headers (`x-bedrouter-model`, `-requested`, `-class`, `-reason`, `-conversation`, `-classifier`). Pi hands extensions those headers in the `after_provider_response` event, so the status line updates the moment a response starts, before any tokens stream. When the turn ends, the extension asks bedrouter for the conversation's running totals (`GET /v1/conversations/:key`) and appends cost: spend so far (classifier calls included), what the same tokens would have cost on the model the client asked for, and the difference as a percentage. `↑n` counts escalations in this conversation. The line clears when you switch to a non-bedrouter model. A background health check (every `healthPollS` seconds) keeps it honest between turns: if the server dies the line reads `bedrouter: DOWN` and, with `autoStart` on, the extension restarts it and says so.
68
+
69
+ Under a coding agent every request carries tools and a large system prompt, so you will see `execute` and `explore` decided by keywords or the classifier, then `sticky` for the rest of the session, `up:kw:explore` when an explicit design question moves the conversation up, and escalations after failures. `trivial` shows up for bare chat clients, not for Pi.
70
+
71
+ ## Model ids
72
+
73
+ From bedrouter's example config: `auto` (Anthropic ladder, router decides), `auto-oss` (gpt-oss ladder), and the rungs `haiku`, `sonnet`, `opus`, `gpt-oss-20b`, `gpt-oss-120b`. Picking a rung is a floor: bedrouter may still go up (explore, escalation) but not below it. Picking `auto` hands it the whole decision. The `cost` Pi shows for `auto` is the family's execute rung; bedrouter's log and `/bedrouter report` have what was actually charged.
74
+
75
+ If your `~/.pi/agent/settings.json` has an `enabledModels` allowlist, add `bedrouter/auto` (and any rungs you want visible) or the provider's models will be hidden.
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ npm install
81
+ npm run typecheck
82
+ npm test # pure tests
83
+ BEDROUTER_TEST_PATH=~/projects/ai/bedrouter npm test # also exercises locate/start against a built checkout
84
+ ```
85
+
86
+ Layout: `extensions/index.ts` (the extension: events, provider registration, `/bedrouter`), `src/bedrouter.ts` (locate/install/start/stop/health/run), `src/models.ts` (config → Pi models, fit notes), `src/footer.ts` (headers → status line), `src/settings.ts`.
87
+
88
+ ## Publishing
89
+
90
+ Both packages are on npm: `bedrouter` (the server, with the `bedrouter` binary) and `pi-bedrouter` (this extension, which depends on it). The `pi-package` keyword makes the extension discoverable at pi.dev/packages. Release order when both change: publish `bedrouter` first, bump the dependency range here, then publish `pi-bedrouter`.
@@ -0,0 +1,257 @@
1
+ // pi-bedrouter: run bedrouter (the cost-aware Bedrock model router) from inside Pi.
2
+ //
3
+ // - finds the bedrouter install (settings.path or the npm dependency), installs/builds it when missing
4
+ // - starts the server when it is not running, seeding .env / bedrouter.json from the examples
5
+ // - registers a "bedrouter" provider whose models come from bedrouter.json (auto aliases + rungs)
6
+ // - optionally switches the session to bedrouter/auto so nobody has to pick a model
7
+ // - shows which model served each request in the footer, from bedrouter's x-bedrouter-* response headers,
8
+ // plus the running session cost against what the requested model would have cost
9
+ // - /bedrouter status|start|stop|restart|install|doctor|probe|report|log|models|fitnotes|config
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { loadSettings, saveSettings, settingsPath, agentDir, type Settings } from "../src/settings.js";
14
+ import * as br from "../src/bedrouter.js";
15
+ import { fitNotes, piModels } from "../src/models.js";
16
+ import { fromHeaders, statusLine, type LastDecision } from "../src/footer.js";
17
+
18
+ export default async function (pi: ExtensionAPI) {
19
+ let settings = loadSettings();
20
+ let last: LastDecision | null = null;
21
+ let stats: br.ConversationStats | null = null;
22
+ let registeredModelIds: string[] = [];
23
+ let lastCtx: ExtensionContext | null = null; // most recent context, for the background poll to update the footer
24
+ let serverUp: boolean | null = null; // last known health; null = never checked
25
+ let restartAttemptAt = 0; // throttle auto-restarts to one per 60 s
26
+
27
+ const isOurs = (ctx: ExtensionContext) => ctx.model?.provider === settings.providerName;
28
+ const setStatus = (ctx: ExtensionContext, text: string | undefined) => { lastCtx = ctx; if (ctx.hasUI && settings.footer) ctx.ui.setStatus("bedrouter", text); };
29
+ const readyText = () => statusLine(last, stats);
30
+ const notify = (ctx: ExtensionContext, msg: string, type: "info" | "warning" | "error" = "info") => { if (ctx.hasUI) ctx.ui.notify(msg, type); };
31
+ /** Show multi-line output (doctor/report/log) as a visible message without adding it to the model's context. */
32
+ const show = (title: string, body: string) => pi.sendMessage({ customType: "bedrouter", content: `**${title}**\n\n\`\`\`\n${body}\n\`\`\``, display: true }, { triggerTurn: false });
33
+
34
+ /**
35
+ * Register (or re-register) the provider. Source, in order: the running server's /v1/models (always right for the
36
+ * ladder that is actually serving), then bedrouter.json (home dir, then the checkout), then the shipped default.
37
+ */
38
+ async function registerProvider(loc: br.Found | null): Promise<string> {
39
+ let cfg: br.BedrouterConfig | null = null;
40
+ let source = "";
41
+ const live = await br.liveConfig(settings);
42
+ if (live && Object.keys(live.families).length) { cfg = live; source = `${br.baseUrl(settings)}/v1/models`; }
43
+ if (!cfg) {
44
+ const file = br.readConfig(settings, loc);
45
+ if ("config" in file) { cfg = file.config; source = file.path; }
46
+ }
47
+ if (!cfg) { cfg = br.FALLBACK_CONFIG; source = "built-in default ladder (no server, no bedrouter.json yet)"; }
48
+ const models = piModels(cfg, br.baseUrl(settings));
49
+ pi.registerProvider(settings.providerName, {
50
+ name: "bedrouter (Bedrock, routed)",
51
+ baseUrl: br.baseUrl(settings),
52
+ apiKey: process.env.BEDROUTER_API_KEY || "bedrouter",
53
+ api: "anthropic-messages",
54
+ models,
55
+ });
56
+ registeredModelIds = models.map((m) => m.id);
57
+ return `registered ${models.length} models from ${source}: ${registeredModelIds.join(", ")}`;
58
+ }
59
+
60
+ /** Locate → (install) → start → register. Returns a human summary. Never throws. */
61
+ async function bringUp(ctx: ExtensionContext | null, opts: { install?: boolean; start?: boolean } = {}): Promise<{ ok: boolean; lines: string[] }> {
62
+ const lines: string[] = [];
63
+ let loc = br.locate(settings);
64
+ if (!loc.found && opts.install) {
65
+ lines.push(`bedrouter not found (${loc.reason}); installing…`);
66
+ const r = br.install(settings);
67
+ lines.push(r.log.trim().split("\n").slice(-6).join("\n"));
68
+ loc = br.locate(settings);
69
+ }
70
+ if (!loc.found) { lines.push(`bedrouter is not installed: ${loc.reason}. Run /bedrouter install (or: ${loc.installCmd}).`); return { ok: false, lines }; }
71
+ if (!loc.cli && opts.install) {
72
+ lines.push(`bedrouter at ${loc.dir} is not built; building…`);
73
+ const r = br.install(settings);
74
+ lines.push(r.log.trim().split("\n").slice(-6).join("\n"));
75
+ loc = br.locate(settings);
76
+ }
77
+ lines.push(await registerProvider(loc as br.Found));
78
+ if (opts.start) {
79
+ const r = await br.start(settings, loc as br.Found);
80
+ for (const f of r.created) lines.push(`created ${f} from the example; edit it for this machine (AWS_PROFILE, ladder)`);
81
+ if (r.ok) lines.push(`bedrouter ${r.health.version} up on ${br.baseUrl(settings)} (pid ${r.health.pid}, region ${r.health.region}, classifier ${r.health.classifier ?? "off"})`);
82
+ else { lines.push(r.error); return { ok: false, lines }; }
83
+ }
84
+ return { ok: true, lines };
85
+ }
86
+
87
+ async function autoSelect(ctx: ExtensionContext) {
88
+ if (settings.autoSelect === false || !ctx.hasUI) return;
89
+ if (isOurs(ctx)) return;
90
+ const want = registeredModelIds.includes(settings.autoSelect) ? settings.autoSelect : registeredModelIds.find((id) => id.startsWith("auto")) ?? registeredModelIds[0];
91
+ if (!want) return;
92
+ const model = ctx.modelRegistry.find(settings.providerName, want);
93
+ if (!model) return;
94
+ const ok = await pi.setModel(model);
95
+ if (ok) notify(ctx, `bedrouter: model set to ${settings.providerName}/${want} (bedrouter picks the rung per request; /model to change)`);
96
+ }
97
+
98
+ // ---- startup -----------------------------------------------------------------------------------------------
99
+ // The factory is async, so pi waits for this: the provider exists for `pi --list-models` and `--provider bedrouter`
100
+ // whether or not the server or a checkout is present yet.
101
+ {
102
+ const loc = br.locate(settings);
103
+ await registerProvider(loc.found ? loc : null);
104
+ }
105
+
106
+ pi.on("session_start", async (ev, ctx) => {
107
+ lastCtx = ctx;
108
+ if (ev.reason !== "startup" && ev.reason !== "new") { if (isOurs(ctx)) setStatus(ctx, statusLine(last, stats)); return; }
109
+ last = null; stats = null;
110
+ const h = await br.health(settings);
111
+ serverUp = !!h?.ok;
112
+ if (!h?.ok && settings.autoStart) {
113
+ const r = await bringUp(ctx, { install: true, start: true });
114
+ serverUp = r.ok;
115
+ if (!r.ok) { notify(ctx, `bedrouter: ${r.lines[r.lines.length - 1]}`, "warning"); setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); return; }
116
+ const created = r.lines.filter((l) => l.startsWith("created "));
117
+ if (created.length) notify(ctx, created.join("\n"), "warning");
118
+ } else if (!h?.ok) { setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); return; }
119
+ await autoSelect(ctx);
120
+ if (isOurs(ctx)) setStatus(ctx, "bedrouter: ready");
121
+ });
122
+
123
+ pi.on("model_select", async (ev, ctx) => {
124
+ if (ev.model.provider === settings.providerName) setStatus(ctx, statusLine(last, stats));
125
+ else setStatus(ctx, undefined);
126
+ });
127
+
128
+ // ---- live routing display ------------------------------------------------------------------------------------
129
+ pi.on("after_provider_response", async (ev, ctx) => {
130
+ if (!isOurs(ctx)) return;
131
+ const d = fromHeaders(ev.headers ?? {});
132
+ if (!d) return;
133
+ last = d;
134
+ serverUp = true;
135
+ setStatus(ctx, statusLine(last, stats));
136
+ });
137
+
138
+ pi.on("agent_end", async (_ev, ctx) => {
139
+ if (!isOurs(ctx) || !last?.conversation) return;
140
+ stats = await br.conversation(settings, last.conversation);
141
+ setStatus(ctx, statusLine(last, stats));
142
+ });
143
+
144
+ // ---- background health poll ----------------------------------------------------------------------------------
145
+ // Pi only tells us about responses; a server that dies between turns would leave the footer saying "ready".
146
+ let poll: NodeJS.Timeout | null = null;
147
+ async function checkHealth() {
148
+ const ctx = lastCtx;
149
+ if (!ctx || !isOurs(ctx)) return;
150
+ const h = await br.health(settings);
151
+ const up = !!h?.ok;
152
+ if (up === serverUp) return;
153
+ serverUp = up;
154
+ if (up) { setStatus(ctx, readyText()); if (last) notify(ctx, "bedrouter: back up"); return; }
155
+ setStatus(ctx, "bedrouter: DOWN" + (settings.autoStart ? " · restarting…" : " · /bedrouter start"));
156
+ if (!settings.autoStart || Date.now() - restartAttemptAt < 60_000) return;
157
+ restartAttemptAt = Date.now();
158
+ // same path as startup: locate, build/install if needed, start
159
+ const r = await bringUp(ctx, { install: true, start: true });
160
+ serverUp = r.ok;
161
+ if (r.ok) { setStatus(ctx, readyText()); notify(ctx, `bedrouter: restarted`); }
162
+ else { setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); notify(ctx, `bedrouter: restart failed — ${r.lines[r.lines.length - 1].split("\n")[0]}`, "warning"); }
163
+ }
164
+ if (settings.healthPollS > 0) { poll = setInterval(() => void checkHealth(), settings.healthPollS * 1000); poll.unref(); }
165
+
166
+ pi.on("session_shutdown", async () => { if (poll) clearInterval(poll); /* the server is left running on purpose: other Pi sessions share it */ });
167
+
168
+ // ---- /bedrouter ------------------------------------------------------------------------------------------------
169
+ const SUB = ["status", "start", "stop", "restart", "install", "doctor", "probe", "report", "log", "models", "fitnotes", "config", "help"];
170
+ pi.registerCommand("bedrouter", {
171
+ description: "bedrouter router: status | start | stop | restart | install | doctor | probe | report | log [n] | models | fitnotes | config",
172
+ getArgumentCompletions: (prefix) => SUB.filter((s) => s.startsWith(prefix.trim())).map((value) => ({ value, label: value })),
173
+ handler: async (args, ctx) => {
174
+ const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
175
+ settings = loadSettings();
176
+ const located = br.locate(settings);
177
+ const loc: br.Found | null = located.found ? located : null;
178
+ const need = (): boolean => { if (!loc) notify(ctx, `bedrouter is not installed: ${(located as { reason: string }).reason}. Run /bedrouter install.`, "error"); return !!loc; };
179
+ switch (sub) {
180
+ case "status": {
181
+ const h = await br.health(settings);
182
+ const lines = [
183
+ loc ? `install: ${loc.dir} (${loc.source}, v${loc.version}${loc.cli ? "" : ", NOT BUILT"})` : `install: missing (${(located as { reason: string }).reason})`,
184
+ `home: ${br.homeDir(settings)}`,
185
+ `server: ${h?.ok ? `up on ${br.baseUrl(settings)}, pid ${h.pid}, v${h.version}, region ${h.region}, routing ${h.routing ? "on" : "off"}, classifier ${h.classifier ?? "off"}, up ${h.uptimeS}s` : `down (${br.baseUrl(settings)})`}`,
186
+ `provider: ${settings.providerName} → ${registeredModelIds.length ? registeredModelIds.join(", ") : "(not registered)"}`,
187
+ `session: ${isOurs(ctx) ? `using ${ctx.model?.id}` : `not using bedrouter (model ${ctx.model?.provider}/${ctx.model?.id})`}`,
188
+ `last: ${last ? `${last.requested} → ${last.model} ${last.cls} · ${last.reason}${last.classifier ? ` classifier: ${last.classifier}` : ""}` : "-"}`,
189
+ `settings: ${settingsPath()}${fs.existsSync(settingsPath()) ? "" : " (defaults; /bedrouter config to create)"}`,
190
+ ];
191
+ show("bedrouter status", lines.join("\n"));
192
+ break;
193
+ }
194
+ case "start": case "restart": {
195
+ if (sub === "restart") notify(ctx, await br.stop(settings));
196
+ const r = await bringUp(ctx, { install: false, start: true });
197
+ show(`bedrouter ${sub}`, r.lines.join("\n"));
198
+ serverUp = r.ok;
199
+ if (r.ok) { await autoSelect(ctx); setStatus(ctx, isOurs(ctx) ? readyText() : undefined); }
200
+ break;
201
+ }
202
+ case "stop": notify(ctx, await br.stop(settings)); serverUp = false; setStatus(ctx, isOurs(ctx) ? "bedrouter: DOWN · /bedrouter start" : undefined); break;
203
+ case "install": {
204
+ notify(ctx, "bedrouter: installing/building (this can take a minute)…");
205
+ const r = await bringUp(ctx, { install: true, start: false });
206
+ show("bedrouter install", r.lines.join("\n"));
207
+ break;
208
+ }
209
+ case "doctor": case "probe": {
210
+ if (!need() || !loc) break;
211
+ if (sub === "probe") notify(ctx, "bedrouter: probing every rung with a 1-token request…");
212
+ const r = br.run(settings, loc, sub === "probe" ? ["doctor", "--probe"] : ["doctor"]);
213
+ show(`bedrouter ${sub}`, r.out);
214
+ break;
215
+ }
216
+ case "report": {
217
+ if (!need() || !loc) break;
218
+ const r = br.run(settings, loc, ["report", ...rest]);
219
+ show("bedrouter report", r.out);
220
+ break;
221
+ }
222
+ case "log": {
223
+ const n = Number(rest[0]) || 15;
224
+ const raw = br.tail(br.decisionLog(settings), n);
225
+ const lines = raw.split("\n").map((l) => { try { const j = JSON.parse(l); return `${j.ts.slice(11, 19)} ${String(j.requestedModel ?? "-").padEnd(12)} → ${String(j.routedModel ?? "-").padEnd(12)} ${String(j.class ?? "-").padEnd(7)} ${String(j.classReason ?? "-").padEnd(22)} ${j.outputTokens ?? "-"} out ${j.costUsd != null ? "$" + j.costUsd.toFixed(5) : "-"}${j.escalated ? " ↑" + j.escalationReason : ""}${j.error ? " ✗ " + j.error : ""}`; } catch { return l; } });
226
+ show(`bedrouter log (last ${n})`, lines.join("\n") || "(empty)");
227
+ break;
228
+ }
229
+ case "models": {
230
+ show("bedrouter models", await registerProvider(loc));
231
+ break;
232
+ }
233
+ case "fitnotes": {
234
+ const file = br.readConfig(settings, loc);
235
+ const cfg = "config" in file ? file.config : (await br.liveConfig(settings)) ?? br.FALLBACK_CONFIG;
236
+ const notes = fitNotes(cfg, settings.providerName);
237
+ const wf = path.join(agentDir(), "workflows.json");
238
+ let cur: { models?: Record<string, string> } = {};
239
+ try { cur = JSON.parse(fs.readFileSync(wf, "utf8")); } catch { /* none */ }
240
+ const preview = Object.entries(notes).map(([k, v]) => `${k}: ${v}`).join("\n");
241
+ const ok = !ctx.hasUI || (await ctx.ui.confirm("Write pi-agents model notes?", `Merge these into ${wf} → models:\n\n${preview}`));
242
+ if (!ok) break;
243
+ fs.writeFileSync(wf, JSON.stringify({ ...cur, models: { ...(cur.models ?? {}), ...notes } }, null, 2) + "\n");
244
+ notify(ctx, `bedrouter: wrote ${Object.keys(notes).length} model notes to ${wf}`);
245
+ break;
246
+ }
247
+ case "config": {
248
+ if (!fs.existsSync(settingsPath())) saveSettings(settings);
249
+ show("bedrouter settings", `${settingsPath()}\n\n${fs.readFileSync(settingsPath(), "utf8")}\nKeys: path, home, port, autoStart, autoSelect (model id or false), debug, footer, providerName. Edit the file, then /reload.`);
250
+ break;
251
+ }
252
+ default:
253
+ show("bedrouter", `/bedrouter ${SUB.join(" | ")}\n\nstatus install, server, provider, current model, last decision\nstart start the server if needed (seeds .env / bedrouter.json on first run)\nstop stop the server (shared by all Pi sessions)\ninstall npm install / build the bedrouter dependency\ndoctor credential source + loaded ladder; probe: 1-token call per rung\nreport savings report over the decision log (args pass through: --since, --json)\nlog [n] last n routing decisions\nmodels re-read bedrouter.json and re-register the provider\nfitnotes write pi-agents model notes so the planner defaults to the router\nconfig show/create ${settingsPath()}`);
254
+ }
255
+ },
256
+ });
257
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-bedrouter",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for bedrouter: starts the cost-aware Bedrock model router, registers it as a provider, and shows which model served each request",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "bedrock",
9
+ "aws",
10
+ "model-router",
11
+ "cost",
12
+ "claude",
13
+ "gpt-oss"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Barry Melton",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/bmelton/pi-bedrouter.git"
20
+ },
21
+ "homepage": "https://github.com/bmelton/pi-bedrouter#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/bmelton/pi-bedrouter/issues"
24
+ },
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./extensions/index.ts"
32
+ ]
33
+ },
34
+ "files": [
35
+ "extensions",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "tsx --test test/*.test.ts",
43
+ "prepublishOnly": "npm test && npm run typecheck"
44
+ },
45
+ "dependencies": {
46
+ "bedrouter": "^0.1.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": ">=0.85.0"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
53
+ "@types/node": "^22.0.0",
54
+ "tsx": "^4.19.0",
55
+ "typescript": "^5.6.0"
56
+ }
57
+ }
@@ -0,0 +1,181 @@
1
+ // Everything about the bedrouter process and its HTTP API: locate, install, start, stop, health, config, models.
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { createRequire } from "node:module";
7
+ import type { Settings } from "./settings.js";
8
+
9
+ export type Health = { ok: boolean; region: string; pid: number; version: string; routing: boolean; classifier: string | null; uptimeS: number };
10
+ export type ConversationStats = { key: string; requests: number; costUsd: number; requestedCostUsd: number; classifierCostUsd: number; inputTokens: number; outputTokens: number; escalations: number; class: string | null; routedModel: string | null; requestedModel: string | null; lastTs: string };
11
+ export type Rung = { alias: string; bedrockId: string; inputPerM: number; outputPerM: number };
12
+ export type BedrouterConfig = { families: Record<string, Rung[]>; aliases?: Record<string, string>; routing?: { enabled?: boolean; classes?: Record<string, Record<string, string>>; classifier?: { enabled?: boolean; model?: string } } };
13
+
14
+ export type Found = { found: true; dir: string; cli: string | null; source: "settings.path" | "dependency"; version: string };
15
+ export type Install = Found | { found: false; reason: string; installCmd: string };
16
+
17
+ const require_ = createRequire(import.meta.url);
18
+
19
+ /** Where bedrouter lives: settings.path if set, else the `bedrouter` npm dependency of this package. */
20
+ export function locate(s: Settings): Install {
21
+ const candidates: { dir: string; source: "settings.path" | "dependency" }[] = [];
22
+ if (s.path) candidates.push({ dir: s.path, source: "settings.path" as const });
23
+ try { candidates.push({ dir: path.dirname(require_.resolve("bedrouter/package.json")), source: "dependency" as const }); } catch { /* not installed */ }
24
+ for (const c of candidates) {
25
+ const pkgPath = path.join(c.dir, "package.json");
26
+ if (!fs.existsSync(pkgPath)) continue;
27
+ let version = "?";
28
+ try { version = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version ?? "?"; } catch { /* ignore */ }
29
+ const dist = path.join(c.dir, "dist", "cli.js");
30
+ const cli = fs.existsSync(dist) ? dist : null;
31
+ return { found: true, dir: c.dir, cli, source: c.source, version };
32
+ }
33
+ const here = path.dirname(path.dirname(new URL(import.meta.url).pathname));
34
+ return { found: false, reason: s.path ? `no package.json at ${s.path}` : "the bedrouter dependency is not installed", installCmd: `npm install --no-audit --no-fund --prefix "${here}"` };
35
+ }
36
+
37
+ /** Install the dependency (npm install in this package's directory) or build dist/ in a checkout. Returns the log. */
38
+ export function install(s: Settings): { ok: boolean; log: string } {
39
+ const loc = locate(s);
40
+ const here = path.dirname(path.dirname(new URL(import.meta.url).pathname));
41
+ const cwd = loc.found ? loc.dir : here;
42
+ const args = loc.found ? ["run", "build"] : ["install", "--no-audit", "--no-fund"];
43
+ const r = spawnSync("npm", args, { cwd, encoding: "utf8", timeout: 300_000, env: { ...process.env, npm_config_loglevel: "error" } });
44
+ const log = `$ npm ${args.join(" ")} (in ${cwd})\n${r.stdout ?? ""}${r.stderr ?? ""}`;
45
+ if (r.status === 0 && loc.found && !fs.existsSync(path.join(loc.dir, "dist", "cli.js"))) return { ok: false, log: log + "\nbuild produced no dist/cli.js" };
46
+ if (r.status === 0 && !loc.found) {
47
+ // a git dependency runs its own `prepare` (build) during npm install; make sure it did
48
+ const again = locate(s);
49
+ if (!again.found) return { ok: false, log: log + "\nbedrouter still not resolvable after install" };
50
+ if (!again.cli) return install(s); // installed but not built (e.g. prepare skipped): build it
51
+ }
52
+ return { ok: r.status === 0, log };
53
+ }
54
+
55
+ export const homeDir = (s: Settings) => s.home ?? s.path ?? path.join(os.homedir(), ".bedrouter");
56
+ export const baseUrl = (s: Settings) => `http://127.0.0.1:${s.port}`;
57
+
58
+ /** Make sure the working directory has .env and bedrouter.json, seeding from the package's examples. Returns what was created. */
59
+ export function ensureHome(s: Settings, loc: Found): string[] {
60
+ const home = homeDir(s);
61
+ fs.mkdirSync(home, { recursive: true });
62
+ const created: string[] = [];
63
+ const seed = (name: string, example: string) => {
64
+ const dst = path.join(home, name), src = path.join(loc.dir, example);
65
+ if (!fs.existsSync(dst) && fs.existsSync(src)) { fs.copyFileSync(src, dst); created.push(dst); }
66
+ };
67
+ seed(".env", ".env.example");
68
+ seed("bedrouter.json", "bedrouter.example.json");
69
+ return created;
70
+ }
71
+
72
+ export function readConfig(s: Settings, loc: Found | null): { path: string; config: BedrouterConfig } | { path: string; error: string } {
73
+ const home = homeDir(s);
74
+ const candidates = [path.join(home, "bedrouter.json"), ...(loc ? [path.join(loc.dir, "bedrouter.json"), path.join(loc.dir, "bedrouter.example.json")] : [])];
75
+ const p = candidates.find((c) => fs.existsSync(c));
76
+ if (!p) return { path: candidates[0], error: "no bedrouter.json found" };
77
+ try { return { path: p, config: JSON.parse(fs.readFileSync(p, "utf8")) }; } catch (e) { return { path: p, error: (e as Error).message }; }
78
+ }
79
+
80
+ async function getJson<T>(url: string, timeoutMs = 1500): Promise<T | null> {
81
+ try {
82
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
83
+ if (!r.ok) return null;
84
+ return (await r.json()) as T;
85
+ } catch { return null; }
86
+ }
87
+
88
+ export const health = (s: Settings) => getJson<Health>(`${baseUrl(s)}/health`);
89
+
90
+ /** Reconstruct a BedrouterConfig from a running server's /v1/models, so the provider can be registered with no local files. */
91
+ export async function liveConfig(s: Settings): Promise<BedrouterConfig | null> {
92
+ type M = { id: string; bedrock_id: string; bedrouter?: { family: string; rung: string; auto: boolean; inputPerM: number; outputPerM: number } };
93
+ const r = await getJson<{ data: M[] }>(`${baseUrl(s)}/v1/models`);
94
+ if (!r?.data) return null;
95
+ const families: Record<string, Rung[]> = {};
96
+ const aliases: Record<string, string> = {};
97
+ for (const m of r.data) {
98
+ const b = m.bedrouter;
99
+ if (!b) continue;
100
+ if (b.auto) { aliases[m.id] = `auto:${b.family}`; continue; }
101
+ const fam = (families[b.family] ??= []);
102
+ if (m.id === b.rung) { if (!fam.some((x) => x.alias === b.rung)) fam.push({ alias: b.rung, bedrockId: m.bedrock_id, inputPerM: b.inputPerM, outputPerM: b.outputPerM }); }
103
+ else aliases[m.id] = b.rung;
104
+ }
105
+ // /v1/models is a map, so ladder order is lost; restore cheapest-first by input price (what bedrouter's ladders are)
106
+ for (const fam of Object.values(families)) fam.sort((a, b) => a.inputPerM - b.inputPerM);
107
+ return { families, aliases };
108
+ }
109
+
110
+ /** Last resort when neither a server nor a config file is available: the shipped example ladder. */
111
+ export const FALLBACK_CONFIG: BedrouterConfig = {
112
+ families: {
113
+ anthropic: [
114
+ { alias: "haiku", bedrockId: "us.anthropic.claude-haiku-4-5-20251001-v1:0", inputPerM: 1.1, outputPerM: 5.5 },
115
+ { alias: "sonnet", bedrockId: "us.anthropic.claude-sonnet-5", inputPerM: 2.2, outputPerM: 11 },
116
+ { alias: "opus", bedrockId: "us.anthropic.claude-opus-5", inputPerM: 5.5, outputPerM: 27.5 },
117
+ ],
118
+ openai: [
119
+ { alias: "gpt-oss-20b", bedrockId: "openai.gpt-oss-20b-1:0", inputPerM: 0.07, outputPerM: 0.2 },
120
+ { alias: "gpt-oss-120b", bedrockId: "openai.gpt-oss-120b-1:0", inputPerM: 0.15, outputPerM: 0.6 },
121
+ ],
122
+ },
123
+ aliases: { auto: "auto:anthropic", "auto-oss": "auto:openai" },
124
+ routing: { classes: { anthropic: { trivial: "haiku", execute: "sonnet", explore: "opus" }, openai: { execute: "gpt-oss-20b", explore: "gpt-oss-120b" } } },
125
+ };
126
+ export const conversation = (s: Settings, key: string) => getJson<ConversationStats>(`${baseUrl(s)}/v1/conversations/${key}`);
127
+
128
+ export const serverLog = (s: Settings) => path.join(homeDir(s), "server.log");
129
+ export const decisionLog = (s: Settings) => path.join(homeDir(s), "bedrouter.log.jsonl");
130
+
131
+ /** Start the server detached; stdout/stderr go to server.log in the home dir. Resolves with health, or the log tail on failure. */
132
+ export async function start(s: Settings, locIn: Found): Promise<{ ok: true; health: Health; created: string[] } | { ok: false; error: string; created: string[] }> {
133
+ let loc = locIn;
134
+ const already = await health(s);
135
+ if (already?.ok) return { ok: true, health: already, created: [] };
136
+ const created = ensureHome(s, loc);
137
+ const home = homeDir(s);
138
+ if (!loc.cli) {
139
+ // not built yet (fresh checkout, or a dependency whose prepare step did not run): build once, then continue
140
+ const b = install(s);
141
+ const again = locate(s);
142
+ if (!b.ok || !again.found || !again.cli) return { ok: false, error: `bedrouter at ${loc.dir} has no dist/cli.js and the build failed:\n${b.log.trim().split("\n").slice(-8).join("\n")}`, created };
143
+ loc = again;
144
+ }
145
+ const out = fs.openSync(serverLog(s), "a");
146
+ fs.writeSync(out, `\n--- pi-bedrouter start ${new Date().toISOString()} ---\n`);
147
+ const cli: string = loc.cli!;
148
+ const child = spawn(process.execPath, [cli, "serve"], {
149
+ cwd: home, detached: true, stdio: ["ignore", out, out],
150
+ env: { ...process.env, PORT: String(s.port), BEDROUTER_DEBUG: s.debug ? "1" : process.env.BEDROUTER_DEBUG ?? "", BEDROUTER_LOG: decisionLog(s) },
151
+ });
152
+ child.unref();
153
+ fs.closeSync(out);
154
+ const deadline = Date.now() + 12_000;
155
+ while (Date.now() < deadline) {
156
+ await new Promise((r) => setTimeout(r, 250));
157
+ const h = await health(s);
158
+ if (h?.ok) return { ok: true, health: h, created };
159
+ if (child.exitCode !== null) break;
160
+ }
161
+ return { ok: false, error: `bedrouter did not come up on ${baseUrl(s)}:\n${tail(serverLog(s), 12)}`, created };
162
+ }
163
+
164
+ export async function stop(s: Settings): Promise<string> {
165
+ const h = await health(s);
166
+ if (!h?.ok) return "bedrouter is not running";
167
+ try { process.kill(h.pid, "SIGTERM"); } catch (e) { return `could not signal pid ${h.pid}: ${(e as Error).message}`; }
168
+ for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 150)); if (!(await health(s))) return `stopped bedrouter (pid ${h.pid})`; }
169
+ return `sent SIGTERM to pid ${h.pid} but it is still answering; check ${serverLog(s)}`;
170
+ }
171
+
172
+ export function tail(file: string, n: number): string {
173
+ try { const lines = fs.readFileSync(file, "utf8").trimEnd().split("\n"); return lines.slice(-n).join("\n"); } catch { return `(no ${file})`; }
174
+ }
175
+
176
+ /** Run a bedrouter subcommand (doctor/report/smoke) in the home dir and capture its output. */
177
+ export function run(s: Settings, loc: Found, args: string[], timeoutMs = 120_000): { code: number; out: string } {
178
+ if (!loc.cli) return { code: 1, out: "bedrouter is not built; run /bedrouter install" };
179
+ const r = spawnSync(process.execPath, [loc.cli, ...args], { cwd: homeDir(s), encoding: "utf8", timeout: timeoutMs, env: { ...process.env, PORT: String(s.port), BEDROUTER_LOG: decisionLog(s), FORCE_COLOR: "0" } });
180
+ return { code: r.status ?? 1, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() || (r.error ? String(r.error) : "") };
181
+ }
package/src/footer.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Footer status text: what served the last request and how the session is doing on cost.
2
+ import type { ConversationStats } from "./bedrouter.js";
3
+
4
+ export type LastDecision = { model: string; requested: string; cls: string; reason: string; conversation?: string; classifier?: string };
5
+
6
+ export function fromHeaders(h: Record<string, string>): LastDecision | null {
7
+ const get = (k: string) => h[k] ?? h[k.toLowerCase()] ?? (Object.entries(h).find(([kk]) => kk.toLowerCase() === k)?.[1]);
8
+ const model = get("x-bedrouter-model");
9
+ if (!model) return null;
10
+ return { model, requested: get("x-bedrouter-requested") ?? "?", cls: get("x-bedrouter-class") ?? "-", reason: get("x-bedrouter-reason") ?? "-", conversation: get("x-bedrouter-conversation"), classifier: get("x-bedrouter-classifier") };
11
+ }
12
+
13
+ const usd = (n: number) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`);
14
+ const short = (reason: string) => reason.replace(/^keyword:/, "kw:").replace(/^classifier:/, "clf:").replace(/^upgrade:keyword:/, "up:kw:").replace(/^shape:/, "");
15
+
16
+ export function statusLine(d: LastDecision | null, c: ConversationStats | null): string {
17
+ if (!d) return "bedrouter: ready";
18
+ const arrow = d.model === d.requested ? "=" : "≠";
19
+ let s = `⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
20
+ if (c && c.requests > 0) {
21
+ const spend = c.costUsd + c.classifierCostUsd;
22
+ const diff = c.requestedCostUsd - spend;
23
+ const pct = c.requestedCostUsd > 0 ? Math.round((diff / c.requestedCostUsd) * 100) : 0;
24
+ s += ` ${usd(spend)}${diff >= 0 ? ` saved ${usd(diff)} (${pct}%)` : ` +${usd(-diff)} over asked-for`}${c.escalations ? ` ↑${c.escalations}` : ""}`;
25
+ }
26
+ return s;
27
+ }
package/src/models.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Turn bedrouter's config into Pi provider model definitions.
2
+ import type { BedrouterConfig, Rung } from "./bedrouter.js";
3
+
4
+ export type PiModel = {
5
+ id: string; name: string; api: "anthropic-messages" | "openai-completions"; baseUrl: string; reasoning: boolean;
6
+ input: ("text" | "image")[]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number;
7
+ };
8
+
9
+ const FAMILY: Record<string, { api: PiModel["api"]; path: string; input: PiModel["input"]; contextWindow: number; maxTokens: number }> = {
10
+ anthropic: { api: "anthropic-messages", path: "", input: ["text", "image"], contextWindow: 200_000, maxTokens: 64_000 },
11
+ openai: { api: "openai-completions", path: "/v1", input: ["text"], contextWindow: 128_000, maxTokens: 32_000 },
12
+ };
13
+
14
+ const cost = (r: Rung) => ({ input: r.inputPerM, output: r.outputPerM, cacheRead: +(r.inputPerM * 0.1).toFixed(4), cacheWrite: +(r.inputPerM * 1.25).toFixed(4) });
15
+
16
+ /** `auto` aliases first (what most people should pick), then each family's rungs. Client aliases (claude-sonnet-5 → sonnet) are omitted to keep /model short. */
17
+ export function piModels(cfg: BedrouterConfig, base: string): PiModel[] {
18
+ const out: PiModel[] = [];
19
+ for (const [alias, target] of Object.entries(cfg.aliases ?? {})) {
20
+ const m = /^auto:(\w+)$/.exec(target);
21
+ if (!m) continue;
22
+ const family = m[1], rungs = cfg.families[family], f = FAMILY[family];
23
+ if (!rungs?.length || !f) continue;
24
+ const exec = cfg.routing?.classes?.[family]?.execute;
25
+ const rep = rungs.find((r) => r.alias === exec) ?? rungs[0];
26
+ out.push({ id: alias, name: `Auto · ${family} ladder (${rungs.map((r) => r.alias).join(" → ")})`, api: f.api, baseUrl: base + f.path, reasoning: true, input: f.input, cost: cost(rep), contextWindow: f.contextWindow, maxTokens: f.maxTokens });
27
+ }
28
+ for (const [family, rungs] of Object.entries(cfg.families)) {
29
+ const f = FAMILY[family];
30
+ if (!f) continue;
31
+ for (const r of rungs) out.push({ id: r.alias, name: `${r.alias} · ${family} (${r.bedrockId})`, api: f.api, baseUrl: base + f.path, reasoning: true, input: f.input, cost: cost(r), contextWindow: f.contextWindow, maxTokens: f.maxTokens });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** pi-agents "fit notes" (~/.pi/agent/workflows.json → models) so the planner prefers the router and never pins premium rungs by default. */
37
+ export function fitNotes(cfg: BedrouterConfig, provider: string): Record<string, string> {
38
+ const notes: Record<string, string> = {};
39
+ for (const [alias, target] of Object.entries(cfg.aliases ?? {})) {
40
+ if (/^auto:/.test(target)) notes[`${provider}/${alias}`] = "default for every node: bedrouter picks the cheapest adequate model per request and escalates on failure";
41
+ }
42
+ for (const [family, rungs] of Object.entries(cfg.families)) {
43
+ const classes = cfg.routing?.classes?.[family] ?? {};
44
+ for (const r of rungs) {
45
+ const cls = Object.entries(classes).find(([, a]) => a === r.alias)?.[0];
46
+ notes[`${provider}/${r.alias}`] = cls === "explore" ? "pin only for planning, final review, reduces" : cls === "trivial" ? "pin only for titles, summaries, extraction" : cls === "execute" ? "pin only when a node must not be routed; ordinary implementation" : "pinned rung, bypasses routing";
47
+ }
48
+ }
49
+ return notes;
50
+ }
@@ -0,0 +1,48 @@
1
+ // pi-bedrouter settings: ~/.pi/agent/pi-bedrouter.json (all keys optional).
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export type Settings = {
7
+ /** Path to a bedrouter checkout or install. Default: the `bedrouter` package this extension depends on. */
8
+ path?: string;
9
+ /** Working directory for the server: holds .env, bedrouter.json, bedrouter.log.jsonl, server.log. Default: `path` when set, else ~/.bedrouter. */
10
+ home?: string;
11
+ /** Port to run/expect bedrouter on. */
12
+ port: number;
13
+ /** Start the server on session start when it is not running. */
14
+ autoStart: boolean;
15
+ /** Model id (from bedrouter's aliases, e.g. "auto" or "auto-oss") to switch the session to when bedrouter is healthy; false to leave the model alone. */
16
+ autoSelect: string | false;
17
+ /** Start the server with BEDROUTER_DEBUG=1 (per-request trace in server.log). */
18
+ debug: boolean;
19
+ /** Show the routing status line in Pi's footer. */
20
+ footer: boolean;
21
+ /** Provider name registered in Pi. */
22
+ providerName: string;
23
+ /** Seconds between background health checks that keep the footer honest and restart a dead server (0 disables). */
24
+ healthPollS: number;
25
+ };
26
+
27
+ export const DEFAULTS: Settings = { port: 20129, autoStart: true, autoSelect: "auto", debug: false, footer: true, providerName: "bedrouter", healthPollS: 15 };
28
+
29
+ export const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
30
+ export const settingsPath = () => path.join(agentDir(), "pi-bedrouter.json");
31
+ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
32
+
33
+ export function loadSettings(): Settings {
34
+ let user: Partial<Settings> = {};
35
+ try { user = JSON.parse(fs.readFileSync(settingsPath(), "utf8")); } catch { /* none yet */ }
36
+ const s: Settings = { ...DEFAULTS, ...user };
37
+ if (s.path) s.path = expand(s.path);
38
+ if (s.home) s.home = expand(s.home);
39
+ if (process.env.BEDROUTER_PORT) s.port = Number(process.env.BEDROUTER_PORT);
40
+ return s;
41
+ }
42
+
43
+ export function saveSettings(s: Partial<Settings>): void {
44
+ fs.mkdirSync(agentDir(), { recursive: true });
45
+ let cur: Partial<Settings> = {};
46
+ try { cur = JSON.parse(fs.readFileSync(settingsPath(), "utf8")); } catch { /* none */ }
47
+ fs.writeFileSync(settingsPath(), JSON.stringify({ ...cur, ...s }, null, 2) + "\n");
48
+ }