@alexeiled/claude-router 0.2.2 → 0.4.1

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/lib/store.mjs CHANGED
@@ -1,7 +1,19 @@
1
1
  // Files: user config, per-session memory and the decision log. The only module that touches the filesystem.
2
- import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import {
3
+ appendFileSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
3
12
  import { join } from 'node:path';
4
13
 
14
+ export const LOG_LIMIT_BYTES = 20 * 1024 * 1024;
15
+ const SESSION_TTL_MS = 30 * 24 * 3_600_000;
16
+
5
17
  export function readJsonFile(path) {
6
18
  try {
7
19
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -31,6 +43,33 @@ export function appendLog(dir, entry) {
31
43
  appendFileSync(join(dir, 'decisions.jsonl'), `${JSON.stringify(entry)}\n`);
32
44
  }
33
45
 
46
+ // Keeps one previous generation: `<file>.1`. Only for files written by path, not held open.
47
+ export function rotate(path, limitBytes = LOG_LIMIT_BYTES) {
48
+ try {
49
+ if (statSync(path).size > limitBytes) renameSync(path, `${path}.1`);
50
+ } catch (error) {
51
+ if (error.code !== 'ENOENT') throw error;
52
+ }
53
+ }
54
+
55
+ // Bounds the data directory of a long-running gateway: the decision log and a month of session memory.
56
+ // gateway.log is the daemon's open stderr, so the hook that starts the daemon rotates it instead.
57
+ export function housekeeping(dir, now = Date.now()) {
58
+ rotate(join(dir, 'decisions.jsonl'));
59
+ const sessions = join(dir, 'sessions');
60
+ let names;
61
+ try {
62
+ names = readdirSync(sessions);
63
+ } catch (error) {
64
+ if (error.code === 'ENOENT') return;
65
+ throw error;
66
+ }
67
+ for (const name of names) {
68
+ const path = join(sessions, name);
69
+ if (now - statSync(path).mtimeMs > SESSION_TTL_MS) rmSync(path, { force: true });
70
+ }
71
+ }
72
+
34
73
  function memoryPath(dir, sessionId) {
35
74
  return join(dir, 'sessions', `${String(sessionId).replace(/[^\w.-]/g, '_')}.json`);
36
75
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alexeiled/claude-router",
3
- "version": "0.2.2",
4
- "description": "Claude Code plugin: a local gateway that selects a model and an effort level for each user turn with a TypeSafe Jev Choice.",
3
+ "version": "0.4.1",
4
+ "description": "Claude Code plugin: auto-picks the right model and effort for each turn using Jev routing.",
5
5
  "license": "MIT",
6
6
  "author": "Alexei Ledenev",
7
7
  "repository": {
@@ -1,16 +1,30 @@
1
1
  #!/usr/bin/env node
2
- // SessionStart hook: start the gateway daemon when nothing answers on its port. Detached, so it outlives the session.
2
+ // Hook entry (SessionStart, UserPromptSubmit): make sure a gateway of this plugin version or newer answers on the port.
3
+ // Starts one on a free port and replaces an older one; leaves a newer one alone, so a session that still runs an older
4
+ // plugin never downgrades it. Detached, so the gateway outlives the session. After a crash, the next prompt restarts it.
5
+ // `--quiet` prints nothing: UserPromptSubmit output goes into the model's context.
3
6
  import { spawn } from 'node:child_process';
4
7
  import { mkdirSync, openSync } from 'node:fs';
5
8
  import { connect } from 'node:net';
6
9
  import { dirname, join } from 'node:path';
7
10
  import { fileURLToPath } from 'node:url';
8
11
  import { loadRuntime } from '../lib/runtime.mjs';
12
+ import { isOlderVersion, ROUTER_VERSION, STATUS_PATH } from '../lib/status.mjs';
13
+ import { rotate } from '../lib/store.mjs';
14
+
15
+ const WAIT_STEP_MS = 100;
16
+ const WAIT_STEPS = 20;
17
+ const STATUS_TIMEOUT_MS = 500;
9
18
 
10
19
  const env = process.env;
20
+ const quiet = process.argv.includes('--quiet');
11
21
  const { config, dataDir } = loadRuntime(env);
12
22
  const port = config.gateway.port;
13
23
 
24
+ function say(message) {
25
+ if (!quiet) process.stdout.write(`router: ${message}\n`);
26
+ }
27
+
14
28
  function probe() {
15
29
  return new Promise((resolve) => {
16
30
  const socket = connect({ host: '127.0.0.1', port });
@@ -22,15 +36,56 @@ function probe() {
22
36
  });
23
37
  }
24
38
 
25
- const configured = (env.ANTHROPIC_BASE_URL ?? '').includes(`127.0.0.1:${port}`);
26
- if (!configured)
27
- process.stdout.write(
28
- 'router: Claude Code does not use the gateway yet. Run /router:setup, then restart Claude Code.\n',
29
- );
39
+ // Waits until the port answers (`true`) or is free (`false`).
40
+ async function waitFor(answering) {
41
+ for (let i = 0; i < WAIT_STEPS; i += 1) {
42
+ if ((await probe()) === answering) return true;
43
+ await new Promise((resolve) => setTimeout(resolve, WAIT_STEP_MS));
44
+ }
45
+ return false;
46
+ }
47
+
48
+ async function runningGateway() {
49
+ try {
50
+ const res = await fetch(`http://127.0.0.1:${port}${STATUS_PATH}`, {
51
+ signal: AbortSignal.timeout(STATUS_TIMEOUT_MS),
52
+ });
53
+ return res.ok ? await res.json() : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // SIGTERM: the old gateway releases the port at once and finishes its open streams. Gateways from 0.4.0 on report
60
+ // their pid; older ones cannot be retired from here.
61
+ async function retire(running) {
62
+ const from = running.version ?? 'before 0.3.0';
63
+ if (!running.pid) {
64
+ say(
65
+ `gateway ${from} runs on port ${port}. Stop it once with \`pkill -f scripts/gateway.mjs\`; the next prompt starts ${ROUTER_VERSION}.`,
66
+ );
67
+ return false;
68
+ }
69
+ try {
70
+ process.kill(running.pid, 'SIGTERM');
71
+ } catch (error) {
72
+ say(`cannot stop gateway ${from} (pid ${running.pid}): ${error.message}`);
73
+ return false;
74
+ }
75
+ if (await waitFor(false)) return true;
76
+ say(`gateway ${from} (pid ${running.pid}) did not release port ${port}`);
77
+ return false;
78
+ }
30
79
 
31
- if (!(await probe())) {
80
+ async function start() {
32
81
  mkdirSync(dataDir, { recursive: true });
33
- const log = openSync(join(dataDir, 'gateway.log'), 'a');
82
+ const logPath = join(dataDir, 'gateway.log');
83
+ try {
84
+ rotate(logPath);
85
+ } catch {
86
+ // Rotation is housekeeping; a gateway that starts matters more.
87
+ }
88
+ const log = openSync(logPath, 'a');
34
89
  // Claude Code exports the plugin option as CLAUDE_PLUGIN_OPTION_<KEY>; the gateway reads one name only.
35
90
  const childEnv = {
36
91
  ...env,
@@ -43,13 +98,20 @@ if (!(await probe())) {
43
98
  });
44
99
  child.unref();
45
100
  // Wait for the listener so the session's first request finds it.
46
- let up = false;
47
- for (let i = 0; i < 20 && !up; i += 1) {
48
- await new Promise((r) => setTimeout(r, 100));
49
- up = await probe();
50
- }
51
- process.stdout.write(
52
- `router: gateway on 127.0.0.1:${port} ${up ? 'started' : 'not answering yet'} (pid ${child.pid})\n`,
53
- );
101
+ return { pid: child.pid, up: await waitFor(true) };
102
+ }
103
+
104
+ if (!(env.ANTHROPIC_BASE_URL ?? '').includes(`127.0.0.1:${port}`))
105
+ say('Claude Code does not use the gateway yet. Run /router:setup, then restart Claude Code.');
106
+
107
+ const running = await runningGateway();
108
+ if (running && !isOlderVersion(running.version, ROUTER_VERSION)) process.exit(0);
109
+ if (running && !(await retire(running))) process.exit(0);
110
+ if (!running && (await probe())) {
111
+ say(`port ${port} answers, but not as the router gateway`);
112
+ process.exit(0);
54
113
  }
114
+ const { pid, up } = await start();
115
+ const verb = running ? `replaced ${running.version ?? 'an older gateway'} on` : 'started on';
116
+ say(`gateway ${ROUTER_VERSION} ${up ? verb : 'not answering yet on'} 127.0.0.1:${port} (pid ${pid})`);
55
117
  process.exit(0);
@@ -1,12 +1,71 @@
1
1
  #!/usr/bin/env node
2
- // Daemon entry: local gateway on 127.0.0.1:<gateway.port>. Started by the SessionStart hook or by hand.
2
+ // Daemon entry: local gateway on 127.0.0.1:<gateway.port>. Started by the plugin hooks or by hand.
3
3
  import { createGateway } from '../lib/gateway.mjs';
4
+ import { IdleTracker } from '../lib/idle.mjs';
4
5
  import { Router } from '../lib/router.mjs';
5
6
  import { loadRuntime } from '../lib/runtime.mjs';
7
+ import { ROUTER_VERSION } from '../lib/status.mjs';
8
+ import { housekeeping } from '../lib/store.mjs';
9
+
10
+ // On stop the port is released at once; open streams get this long to finish (Claude Code's request timeout).
11
+ const DRAIN_MS = 600_000;
12
+ const HOUSEKEEPING_MS = 3_600_000;
13
+ const IDLE_CHECK_MS = 60_000;
14
+
15
+ process.stderr.on('error', () => {}); // a full disk must not turn a log line into a crash
16
+ const log = (message) => process.stderr.write(`${new Date().toISOString()} router: ${message}\n`);
17
+ const onError = (error) => log(error.message);
18
+
19
+ // Every Claude Code session on the machine goes through this process, so a stray exception is logged, not fatal.
20
+ // Request state is per request and per session; the worst case after such an error is one misrouted turn.
21
+ process.on('uncaughtException', (error) => log(`uncaught exception: ${error.stack ?? error.message}`));
22
+ process.on('unhandledRejection', (reason) => log(`unhandled rejection: ${reason?.stack ?? reason}`));
6
23
 
7
24
  const { config, dataDir } = loadRuntime(process.env);
8
- const router = new Router({ config, fetchFn: globalThis.fetch, dataDir });
9
- const server = createGateway({ router, onError: (error) => process.stderr.write(`router: ${error.message}\n`) });
10
- server.listen(config.gateway.port, '127.0.0.1', () =>
11
- process.stdout.write(`router: listening on http://127.0.0.1:${config.gateway.port} as ${config.gateway.alias}\n`),
25
+ const { port, alias, idleShutdownMs } = config.gateway;
26
+ const router = new Router({ config, fetchFn: globalThis.fetch, dataDir, onError });
27
+ const activity = new IdleTracker();
28
+ const server = createGateway({ router, onError, activity });
29
+
30
+ server.on('error', (error) => {
31
+ if (error.code === 'EADDRINUSE') {
32
+ log(`port ${port} is in use, another gateway serves it: exiting`);
33
+ process.exit(0);
34
+ }
35
+ log(`server error: ${error.message}`);
36
+ process.exit(1);
37
+ });
38
+
39
+ let stopping = false;
40
+ function stop(reason) {
41
+ if (stopping) return;
42
+ stopping = true;
43
+ log(`${reason}: no new connections, finishing open streams`);
44
+ server.close(() => process.exit(0));
45
+ server.closeIdleConnections();
46
+ setTimeout(() => process.exit(0), DRAIN_MS).unref();
47
+ }
48
+ process.once('SIGTERM', () => stop('SIGTERM'));
49
+ process.once('SIGINT', () => stop('SIGINT'));
50
+
51
+ if (idleShutdownMs > 0) {
52
+ const minutes = Math.round(idleShutdownMs / 60_000);
53
+ const check = () => {
54
+ if (activity.idle(idleShutdownMs)) stop(`idle for ${minutes} min, no turn waits for a tool`);
55
+ };
56
+ setInterval(check, Math.min(idleShutdownMs, IDLE_CHECK_MS)).unref();
57
+ }
58
+
59
+ function tidy() {
60
+ try {
61
+ housekeeping(dataDir);
62
+ } catch (error) {
63
+ onError(error);
64
+ }
65
+ }
66
+ tidy();
67
+ setInterval(tidy, HOUSEKEEPING_MS).unref();
68
+
69
+ server.listen(port, '127.0.0.1', () =>
70
+ log(`${ROUTER_VERSION} listening on http://127.0.0.1:${port} as ${alias} (pid ${process.pid})`),
12
71
  );
@@ -1,24 +1,29 @@
1
1
  ---
2
2
  name: setup
3
- description: Point Claude Code at the router gateway. Adds `model`, `ANTHROPIC_BASE_URL` and the `/model` picker row to the user settings, and offers the status line.
3
+ description: Point Claude Code at the router gateway. Adds `model`, `ANTHROPIC_BASE_URL`, the context window and the `/model` picker row to the user settings, and offers the status line.
4
4
  disable-model-invocation: true
5
5
  allowed-tools: Read, Edit, Write
6
6
  ---
7
7
 
8
- Configure Claude Code for the router gateway. Do these steps:
8
+ Configure Claude Code for the router gateway. This session does not use the gateway until Claude Code restarts. When the file has `model: "router"`, this session can fail its next request. So ask all questions first, write the file once, and write it last.
9
9
 
10
- 1. Read `~/.claude/settings.json`. If the file does not exist, start from `{}`.
11
- 2. Set the key `model` to `"router"`.
12
- 3. Set the key `env.ANTHROPIC_BASE_URL` to `"http://127.0.0.1:43170"`. If `~/.claude/router.json` sets `gateway.port`, use that port.
13
- 4. Set these keys in `env`, for the `/model` picker row:
14
- - `ANTHROPIC_CUSTOM_MODEL_OPTION`: `"router"`
15
- - `ANTHROPIC_CUSTOM_MODEL_OPTION_NAME`: `"Router (auto)"`
16
- - `ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION`: `"Picks Opus 5.5 / Sonnet 4.6 / Haiku 4.5 and the effort for each turn"`
17
- 5. Keep every other key unchanged. Write the file.
18
- 6. The status line can show the model and effort of the last routed turn. The command is `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs`, followed by the current status line command if there is one (for example `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs claude-powerline`). Show the user the current `statusLine` value and the new one, and ask. Change `statusLine.command` only if the user agrees. Keep the other `statusLine` keys.
19
- 7. Tell the user:
20
- - Restart Claude Code. Then the router serves each turn, and `/router:status` shows the routes and the last turn.
10
+ 1. Read `~/.claude/settings.json`. If the file does not exist, start from `{}`. Read `~/.claude/router.json` if it exists.
11
+ 2. The status line can show the model and effort of the last routed turn. The command is `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs`, followed by the current status line command if there is one (for example `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs claude-powerline`). Show the user the current `statusLine` value and the new one, and ask. Change `statusLine.command` only if the user agrees. Keep the other `statusLine` keys.
12
+ 3. Prepare the new settings object in memory:
13
+ - `model`: `"router"`.
14
+ - `env.ANTHROPIC_BASE_URL`: `"http://127.0.0.1:43170"`. If `router.json` sets `gateway.port`, use that port.
15
+ - `env.CLAUDE_CODE_MAX_CONTEXT_TOKENS`: `"1000000"`. Claude Code does not know the model `router` and assumes a 200K window without this key. If `router.json` changes `routes` or `models`, use the largest `contextWindow` of the models that the routes use. The gateway sends a turn only to a model whose window holds the context.
16
+ - The `/model` picker row. In `modelPicker.options`, replace the row whose `model` is `"router"`, or append it if there is none:
17
+ `{ "model": "router", "label": "Router (auto)", "description": "Auto-selects the model and effort for each turn", "behavesAs": "claude-opus-5-5" }`.
18
+ If `modelPicker` does not exist, set it to `{ "options": [<the row>] }`. Keep the other rows and `replaceBuiltInOptions`. `behavesAs` names a model that Claude Code knows; without it, Claude Code rejects `router` as a model that is not in its catalog. If `router.json` changes the `high` route, use the `id` of its model.
19
+ - Remove `env.ANTHROPIC_CUSTOM_MODEL_OPTION`, `env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME` and `env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION`. Older versions of this setup wrote them; the `modelPicker` row replaces them.
20
+ - The `statusLine` change from step 2, if the user agreed.
21
+ - Keep every other key unchanged.
22
+ 4. Tell the user, before you write the file:
23
+ - Restart Claude Code now. Until the restart, this session can show "There's an issue with the selected model (router)", because it still sends requests to Anthropic and not to the gateway.
24
+ - After the restart, the router serves each turn, and `/router:status` shows the routes and the last turn.
21
25
  - The status line path contains the plugin version. After a plugin update, run `/router:setup` again.
22
- - To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL` and the three `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys, and restore the status line command.
26
+ - To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL`, `env.CLAUDE_CODE_MAX_CONTEXT_TOKENS` and the `router` row of `modelPicker.options`, and restore the status line command.
27
+ 5. Write the whole object to `~/.claude/settings.json` with one Write call. Do not use a sequence of edits. Do nothing after this step.
23
28
 
24
29
  Do not change any other file.
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  name: status
3
- description: Show the router routes, the Jev key state, and the model and effort of the last routed turn.
3
+ description: Show which model served the last turn, whether Jev routing is active, and the routing config.
4
4
  disable-model-invocation: true
5
5
  allowed-tools: Bash(node ${CLAUDE_PLUGIN_ROOT}/scripts/status.mjs *)
6
6
  ---
7
+
7
8
  Router status:
8
9
 
9
10
  !`node ${CLAUDE_PLUGIN_ROOT}/scripts/status.mjs ${CLAUDE_SESSION_ID}`