auto-model-router 0.2.25 → 0.2.26

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.
@@ -19,7 +19,7 @@
19
19
  * - /path/to/auto-model-router/omp-extension/router-toast.ts
20
20
  */
21
21
 
22
- import { readFileSync } from "node:fs";
22
+ import { appendFileSync, readFileSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
 
@@ -52,6 +52,39 @@ function readModelsYml(): string {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * Appends one line to `$AUTO_MODEL_ROUTER_HOME/embed.log`.
57
+ *
58
+ * A FILE, deliberately: `console.*` from an extension does not reach omp's
59
+ * session log, so the first attempt at this diagnostic left no trace anywhere
60
+ * and the port lifecycle had to be reconstructed from netstat and mtimes. Best
61
+ * effort — a logging failure must never break a session.
62
+ */
63
+ function writeEmbedLog(line: string): void {
64
+ try {
65
+ appendFileSync(join(routerHome(), "embed.log"), `${new Date().toISOString()} ${line}\n`, "utf8");
66
+ } catch {
67
+ // Unwritable home: the router still works, we just lose the breadcrumb.
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Stops the router when the PROCESS ends — never when a session does.
73
+ * Idempotent: registered once, however many sessions this process hosts.
74
+ */
75
+ let exitHooked = false;
76
+ function trackProcessExit(): void {
77
+ if (exitHooked) return;
78
+ exitHooked = true;
79
+ // `exit` cannot await, and does not need to: the OS reclaims the socket.
80
+ // The signal hooks exist so a Ctrl-C releases the port promptly.
81
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
82
+ process.once(signal, () => {
83
+ void app?.stop().catch(() => {});
84
+ });
85
+ }
86
+ }
87
+
55
88
  /**
56
89
  * Registers the auto-model-router provider (and its virtual models) into omp's model
57
90
  * registry at a specific bound port.
@@ -94,16 +127,29 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
94
127
  });
95
128
  }
96
129
 
130
+ /**
131
+ * The router bound by THIS PROCESS, and the port it serves.
132
+ *
133
+ * Module scope on purpose: Bun caches the module per process, so when omp loads
134
+ * the extension into a second host (its provider-refresh / reload path does
135
+ * exactly that) these stay visible. A second host then REUSES this router
136
+ * instead of binding another port and orphaning every model handle omp already
137
+ * resolved against the first one.
138
+ */
139
+ let app: StartedServer | null = null;
140
+ let boundPort: number | null = null;
141
+
142
+ /** `$AUTO_MODEL_ROUTER_HOME`, tilde-expanded, defaulting to ~/.auto-model-router. */
143
+ function routerHome(): string {
144
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
145
+ return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
146
+ }
147
+
97
148
  export default function (pi: ExtensionAPI): void {
98
149
  pi.setLabel("auto-model-router embed");
99
150
 
100
151
  // Shared port file, written only by the main session's router.
101
- const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
102
- const home =
103
- homeRaw === "~" || homeRaw.startsWith("~/") || homeRaw.startsWith("~\\")
104
- ? join(homedir(), homeRaw.slice(1))
105
- : homeRaw;
106
- const portFile = embedPortPath(home);
152
+ const portFile = embedPortPath(routerHome());
107
153
  // Each interactive session binds its OWN router on an ephemeral port, so
108
154
  // sessions stay independent: no shared process to contend over, and no
109
155
  // session left broken because another one exited. `server.port` from
@@ -113,12 +159,22 @@ export default function (pi: ExtensionAPI): void {
113
159
  const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
114
160
  const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
115
161
 
116
- let app: StartedServer | null = null;
117
-
118
162
  pi.on("session_start", async (_event, ctx) => {
119
163
  // The omp UI session id tags every request so the toast can scope its
120
164
  // notifications to that exact session (see router-toast.ts).
121
165
  const sessionId = ctx.sessionManager.getSessionId();
166
+
167
+ // This module is cached per PROCESS, so `app` and `boundPort` are
168
+ // process-global even when omp loads the extension into more than one
169
+ // host. A router already bound in this process is therefore reusable:
170
+ // re-register it for the new session id and return. Never rebind — a
171
+ // second bind would take a different port and orphan every model handle
172
+ // omp already resolved against the first one.
173
+ if (app !== null && boundPort !== null) {
174
+ registerRouterProvider(pi, boundPort, cfg, sessionId);
175
+ return;
176
+ }
177
+
122
178
  if (!ctx.hasUI) {
123
179
  // Subagents and headless (-p) sessions prefer the main session's
124
180
  // shared router: one process, one ledger, one place to inspect.
@@ -136,11 +192,8 @@ export default function (pi: ExtensionAPI): void {
136
192
  const started = startServer(cfg);
137
193
  if (started.server.port === undefined) return;
138
194
  app = started;
139
- registerRouterProvider(pi, started.server.port, cfg, sessionId);
140
- pi.on("session_shutdown", () => {
141
- void app?.stop().catch(() => {});
142
- app = null;
143
- });
195
+ boundPort = started.server.port;
196
+ registerRouterProvider(pi, boundPort, cfg, sessionId);
144
197
  return;
145
198
  }
146
199
 
@@ -148,7 +201,6 @@ export default function (pi: ExtensionAPI): void {
148
201
  // the provider against the exact bound port. Registration happens only
149
202
  // here — never at factory load, where a stale shared port would be
150
203
  // captured into omp's model registry and defeat the correct bound URL.
151
- if (app) return;
152
204
 
153
205
  // A fixed port was asked for (env var) and a live router already answers
154
206
  // there: share it rather than failing to bind. Ephemeral ports — the
@@ -175,6 +227,7 @@ export default function (pi: ExtensionAPI): void {
175
227
  const actualPort = started.server.port;
176
228
  if (actualPort === undefined) return;
177
229
  app = started;
230
+ boundPort = actualPort;
178
231
 
179
232
  // Publish the port; subagents and the toast read it from here.
180
233
  writeEmbedPort(portFile, actualPort);
@@ -192,24 +245,23 @@ export default function (pi: ExtensionAPI): void {
192
245
  registerRouterProvider(pi, actualPort, cfg, sessionId);
193
246
  pi.setLabel(`auto-model-router embed :${actualPort}${syncAction === null ? "" : ` (models.yml ${syncAction})`}`);
194
247
 
195
- pi.on("session_shutdown", () => {
196
- void app?.stop().catch(() => {});
197
- app = null;
198
- });
248
+ // NO `session_shutdown` teardown. That event is emitted from session
249
+ // DISPOSAL including omp's provider-refresh / extension-reload path,
250
+ // which runs in a throwaway extension host while the real session keeps
251
+ // going. Because this module is cached per process, such a handler stops
252
+ // the LIVE router: every subsequent turn then fails with Bun's
253
+ // "Unable to connect", while utility calls that resolve after a later
254
+ // rebind still work — the exact asymmetry observed in the field. The
255
+ // router's lifetime is the PROCESS, and the OS reclaims the socket when
256
+ // the process exits.
257
+ trackProcessExit();
199
258
 
200
- // Diagnostic, not a guess. "provider error: Unable to connect" on real
201
- // turns while utility calls keep working means omp dialled an address
202
- // this router does not serve — and that text is Bun's fetch error, which
203
- // names no URL. Log the facts that distinguish the cases: the port bound,
204
- // the port models.yml advertised BEFORE the sync above, and whether our
205
- // own socket answers.
206
- const selfProbeOk = await probeEmbed(actualPort);
207
- console.info(
208
- `[auto-model-router] embed ready: serving :${actualPort}` +
209
- ` | models.yml advertised :${advertised ?? "none"} at startup` +
210
- ` | self-probe ${selfProbeOk ? "ok" : "FAILED"}` +
211
- ` | session ${sessionId}`,
259
+ writeEmbedLog(
260
+ `embed ready pid=${process.pid} port=${actualPort}` +
261
+ ` models.yml-advertised=${advertised ?? "none"}` +
262
+ ` sync=${syncAction ?? "current"}` +
263
+ ` self-probe=${(await probeEmbed(actualPort)) ? "ok" : "FAILED"}` +
264
+ ` session=${sessionId}`,
212
265
  );
213
- if (!selfProbeOk) pi.setLabel(`auto-model-router: BOUND :${actualPort} BUT NOT ANSWERING`);
214
266
  });
215
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -0,0 +1,119 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import type { ExtensionAPI, ExtensionContext, ProviderRegistration } from "@oh-my-pi/pi-coding-agent";
7
+
8
+ /**
9
+ * The embedded router's lifetime is the PROCESS, not a session.
10
+ *
11
+ * omp emits `session_shutdown` from session DISPOSAL, and disposal includes its
12
+ * provider-refresh / extension-reload path — which runs in a throwaway
13
+ * extension host while the real session keeps going. This module is cached per
14
+ * process, so tearing the router down in that handler stopped the LIVE router.
15
+ * Every later turn then failed with Bun's "Unable to connect", while utility
16
+ * calls resolved after a subsequent rebind still worked: exactly the asymmetry
17
+ * seen in the field, where only `toolCount: 0` dispatches reached the ledger and
18
+ * the port named in `embed.port` answered nothing.
19
+ *
20
+ * One boot shared by every test here, deliberately: the extension module is
21
+ * cached per process in production too, so this is the real shape.
22
+ */
23
+
24
+ const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => void | Promise<void>)[]>();
25
+ const registrations: { id: string; baseUrl: string }[] = [];
26
+
27
+ const pi: ExtensionAPI = {
28
+ setLabel: () => {},
29
+ on: (event, handler) => {
30
+ const list = handlers.get(event) ?? [];
31
+ list.push(handler);
32
+ handlers.set(event, list);
33
+ },
34
+ registerProvider: (id: string, registration: ProviderRegistration) => {
35
+ registrations.push({ id, baseUrl: registration.baseUrl });
36
+ },
37
+ unregisterProvider: () => {},
38
+ registerCommand: () => {},
39
+ };
40
+
41
+ async function fire(event: string, sessionId: string): Promise<void> {
42
+ const ctx = { hasUI: true, sessionManager: { getSessionId: () => sessionId } } as ExtensionContext;
43
+ for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
44
+ }
45
+
46
+ /** Health-probes the router; used to assert the socket's state, not to wait. */
47
+ async function alive(port: number): Promise<boolean> {
48
+ try {
49
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) });
50
+ return res.ok;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ let home = "";
57
+ let port = 0;
58
+
59
+ beforeAll(async () => {
60
+ home = mkdtempSync(join(tmpdir(), "embed-life-"));
61
+ process.env.AUTO_MODEL_ROUTER_HOME = home;
62
+ process.env.AUTO_MODEL_ROUTER_DB = join(home, "router.db");
63
+ const mod = (await import("../omp-extension/router-embed.ts")) as { default: (api: ExtensionAPI) => void };
64
+ mod.default(pi);
65
+ await fire("session_start", "session-one");
66
+ port = Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
67
+ });
68
+
69
+ afterAll(async () => {
70
+ // Release the socket the way the extension intends: on process signals.
71
+ process.emit("SIGTERM");
72
+ // Poll the real condition rather than guessing a duration; each fetch
73
+ // attempt yields, so this settles as soon as the socket is actually closed.
74
+ for (let i = 0; i < 50 && (await alive(port)); i++) {
75
+ /* keep probing until the port stops answering */
76
+ }
77
+ // A still-open SQLite handle can hold the file on Windows; the temp dir is
78
+ // disposable either way.
79
+ try {
80
+ rmSync(home, { recursive: true, force: true });
81
+ } catch {
82
+ /* leave it to the OS temp reaper */
83
+ }
84
+ });
85
+
86
+ describe("embedded router lifetime", () => {
87
+ test("binds a router and registers it for the session", () => {
88
+ expect(port).toBeGreaterThan(0);
89
+ expect(registrations.at(-1)?.id).toBe("auto-model-router");
90
+ });
91
+
92
+ test("the router answers on the port it registered", async () => {
93
+ expect(await alive(port)).toBe(true);
94
+ });
95
+
96
+ test("registers NO session_shutdown teardown", () => {
97
+ // omp fires this from a throwaway host during provider refresh, so a
98
+ // teardown here kills a router the live session is still using.
99
+ expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
100
+ });
101
+
102
+ test("a session_shutdown leaves the router running", async () => {
103
+ await fire("session_shutdown", "session-one");
104
+ expect(await alive(port)).toBe(true);
105
+ });
106
+
107
+ test("a second session reuses the same port instead of rebinding", async () => {
108
+ // Rebinding would take a different port and orphan every model handle omp
109
+ // had already resolved against the first one.
110
+ await fire("session_start", "session-two");
111
+ const latest = Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
112
+ expect(latest).toBe(port);
113
+ expect(await alive(port)).toBe(true);
114
+ });
115
+
116
+ test("each session still gets its own registration, so per-session tagging survives reuse", () => {
117
+ expect(registrations.length).toBeGreaterThanOrEqual(2);
118
+ });
119
+ });