pi-bedrouter 0.1.0 → 0.2.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/README.md CHANGED
@@ -39,6 +39,7 @@ Bedrouter needs AWS credentials that can call Bedrock; see its README for the `a
39
39
  | `debug` | `false` | Start the server with `BEDROUTER_DEBUG=1` (per-request trace in `server.log`) |
40
40
  | `footer` | `true` | Show the routing status line in Pi's footer |
41
41
  | `providerName` | `"bedrouter"` | Provider name registered in Pi |
42
+ | `stopOnExit` | `"if-started-here"` | What happens to the server when Pi **quits** (`/reload` and session switches never stop it). `if-started-here`: stop it if this session started it and no other client sent a request in the last 5 minutes; `always`: stop it whenever this session started it; `never`: leave it running. Pi tears down its UI before extensions are told about the quit, so this cannot be a prompt; the policy is shown when the server is started and in `/bedrouter status` |
42
43
  | `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
 
44
45
  Example for a developer with a checkout:
@@ -52,7 +53,7 @@ Example for a developer with a checkout:
52
53
  | Command | Does |
53
54
  | --- | --- |
54
55
  | `/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 start` / `stop` / `restart` | Manage the server. It is shared by every Pi session, so `stop` affects all of them; see `stopOnExit` for what happens when Pi quits |
56
57
  | `/bedrouter install` | `npm install` the dependency, or `npm run build` a checkout that has no `dist/` |
57
58
  | `/bedrouter doctor` | Which credential source resolved, expiry, the loaded ladder |
58
59
  | `/bedrouter probe` | One 1-token request per rung: which models this AWS account can actually invoke |
@@ -23,6 +23,8 @@ export default async function (pi: ExtensionAPI) {
23
23
  let lastCtx: ExtensionContext | null = null; // most recent context, for the background poll to update the footer
24
24
  let serverUp: boolean | null = null; // last known health; null = never checked
25
25
  let restartAttemptAt = 0; // throttle auto-restarts to one per 60 s
26
+ let startedHere = false; // this session launched the server (so it may stop it on quit)
27
+ const ourConversations = new Set<string>(); // bedrouter conversation keys seen from this session
26
28
 
27
29
  const isOurs = (ctx: ExtensionContext) => ctx.model?.provider === settings.providerName;
28
30
  const setStatus = (ctx: ExtensionContext, text: string | undefined) => { lastCtx = ctx; if (ctx.hasUI && settings.footer) ctx.ui.setStatus("bedrouter", text); };
@@ -57,6 +59,8 @@ export default async function (pi: ExtensionAPI) {
57
59
  return `registered ${models.length} models from ${source}: ${registeredModelIds.join(", ")}`;
58
60
  }
59
61
 
62
+ const exitPolicyText = () => settings.stopOnExit === "never" ? "left running" : settings.stopOnExit === "always" ? "stopped" : "stopped unless another client is using it";
63
+
60
64
  /** Locate → (install) → start → register. Returns a human summary. Never throws. */
61
65
  async function bringUp(ctx: ExtensionContext | null, opts: { install?: boolean; start?: boolean } = {}): Promise<{ ok: boolean; lines: string[] }> {
62
66
  const lines: string[] = [];
@@ -76,10 +80,13 @@ export default async function (pi: ExtensionAPI) {
76
80
  }
77
81
  lines.push(await registerProvider(loc as br.Found));
78
82
  if (opts.start) {
83
+ const before = await br.health(settings);
79
84
  const r = await br.start(settings, loc as br.Found);
80
85
  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 }; }
86
+ if (r.ok) {
87
+ if (!before?.ok) startedHere = true;
88
+ lines.push(`bedrouter ${r.health.version} up on ${br.baseUrl(settings)} (pid ${r.health.pid}, region ${r.health.region}, classifier ${r.health.classifier ?? "off"})${!before?.ok ? `; started by this session, on quit: ${exitPolicyText()}` : "; was already running (not started here, left alone on quit)"}`);
89
+ } else { lines.push(r.error); return { ok: false, lines }; }
83
90
  }
84
91
  return { ok: true, lines };
85
92
  }
@@ -132,6 +139,7 @@ export default async function (pi: ExtensionAPI) {
132
139
  if (!d) return;
133
140
  last = d;
134
141
  serverUp = true;
142
+ if (d.conversation) ourConversations.add(d.conversation);
135
143
  setStatus(ctx, statusLine(last, stats));
136
144
  });
137
145
 
@@ -163,7 +171,14 @@ export default async function (pi: ExtensionAPI) {
163
171
  }
164
172
  if (settings.healthPollS > 0) { poll = setInterval(() => void checkHealth(), settings.healthPollS * 1000); poll.unref(); }
165
173
 
166
- pi.on("session_shutdown", async () => { if (poll) clearInterval(poll); /* the server is left running on purpose: other Pi sessions share it */ });
174
+ pi.on("session_shutdown", async (ev) => {
175
+ if (poll) clearInterval(poll);
176
+ // Only a real quit ends the server; /reload and session switches keep it (the next session picks it straight up).
177
+ // The TUI is already gone at this point, so the policy is a setting, not a prompt (see stopOnExit).
178
+ if (ev.reason !== "quit" || !startedHere || settings.stopOnExit === "never") return;
179
+ if (settings.stopOnExit === "if-started-here" && (await br.othersActive(settings, ourConversations))) return;
180
+ await br.stop(settings);
181
+ });
167
182
 
168
183
  // ---- /bedrouter ------------------------------------------------------------------------------------------------
169
184
  const SUB = ["status", "start", "stop", "restart", "install", "doctor", "probe", "report", "log", "models", "fitnotes", "config", "help"];
@@ -186,6 +201,7 @@ export default async function (pi: ExtensionAPI) {
186
201
  `provider: ${settings.providerName} → ${registeredModelIds.length ? registeredModelIds.join(", ") : "(not registered)"}`,
187
202
  `session: ${isOurs(ctx) ? `using ${ctx.model?.id}` : `not using bedrouter (model ${ctx.model?.provider}/${ctx.model?.id})`}`,
188
203
  `last: ${last ? `${last.requested} → ${last.model} ${last.cls} · ${last.reason}${last.classifier ? ` classifier: ${last.classifier}` : ""}` : "-"}`,
204
+ `on quit: ${startedHere ? `server ${exitPolicyText()} (stopOnExit: ${settings.stopOnExit})` : "server was not started by this session; left alone"}`,
189
205
  `settings: ${settingsPath()}${fs.existsSync(settingsPath()) ? "" : " (defaults; /bedrouter config to create)"}`,
190
206
  ];
191
207
  show("bedrouter status", lines.join("\n"));
@@ -246,7 +262,7 @@ export default async function (pi: ExtensionAPI) {
246
262
  }
247
263
  case "config": {
248
264
  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.`);
265
+ show("bedrouter settings", `${settingsPath()}\n\n${fs.readFileSync(settingsPath(), "utf8")}\nKeys: path, home, port, autoStart, autoSelect (model id or false), debug, footer, providerName, healthPollS, stopOnExit (if-started-here | always | never). Edit the file, then /reload.`);
250
266
  break;
251
267
  }
252
268
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bedrouter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "keywords": [
6
6
  "pi-package",
package/src/bedrouter.ts CHANGED
@@ -124,6 +124,13 @@ export const FALLBACK_CONFIG: BedrouterConfig = {
124
124
  routing: { classes: { anthropic: { trivial: "haiku", execute: "sonnet", explore: "opus" }, openai: { execute: "gpt-oss-20b", explore: "gpt-oss-120b" } } },
125
125
  };
126
126
  export const conversation = (s: Settings, key: string) => getJson<ConversationStats>(`${baseUrl(s)}/v1/conversations/${key}`);
127
+ export const recentConversations = async (s: Settings) => (await getJson<{ data: ConversationStats[] }>(`${baseUrl(s)}/v1/conversations`))?.data ?? [];
128
+
129
+ /** True when a conversation other than `ours` sent a request within `windowMs`: another client is using the server. */
130
+ export async function othersActive(s: Settings, ours: Set<string>, windowMs = 5 * 60_000, now = Date.now()): Promise<boolean> {
131
+ const recent = await recentConversations(s);
132
+ return recent.some((c) => !ours.has(c.key) && now - Date.parse(c.lastTs) < windowMs);
133
+ }
127
134
 
128
135
  export const serverLog = (s: Settings) => path.join(homeDir(s), "server.log");
129
136
  export const decisionLog = (s: Settings) => path.join(homeDir(s), "bedrouter.log.jsonl");
package/src/settings.ts CHANGED
@@ -22,9 +22,15 @@ export type Settings = {
22
22
  providerName: string;
23
23
  /** Seconds between background health checks that keep the footer honest and restart a dead server (0 disables). */
24
24
  healthPollS: number;
25
+ /**
26
+ * What happens to the server when Pi quits (not on /reload or session switches):
27
+ * "if-started-here": stop it if this session started it and no other client used it in the last few minutes;
28
+ * "always": stop it whenever this session started it; "never": leave it running.
29
+ */
30
+ stopOnExit: "if-started-here" | "always" | "never";
25
31
  };
26
32
 
27
- export const DEFAULTS: Settings = { port: 20129, autoStart: true, autoSelect: "auto", debug: false, footer: true, providerName: "bedrouter", healthPollS: 15 };
33
+ export const DEFAULTS: Settings = { port: 20129, autoStart: true, autoSelect: "auto", debug: false, footer: true, providerName: "bedrouter", healthPollS: 15, stopOnExit: "if-started-here" };
28
34
 
29
35
  export const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
30
36
  export const settingsPath = () => path.join(agentDir(), "pi-bedrouter.json");