claude-org 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.3.0 — 2026-10-10
6
+
7
+ - `proxy`: each log line now names the model (`via <org> model=<model> -> <status>`), and the org and model of the last request are written to a JSON state file whose path is passed to `claude` as `CLAUDE_ORG_STATE_FILE`, for statusline and hooks (#12)
8
+ - `probe` / `status`: `--model <id>` (or `CLAUDE_ORG_PROBE_MODEL`) checks that model instead of Haiku and appends `model_status=available|unavailable|unknown` (and `model_error=` when refused), so "5h allowed but this model is refused" is told apart. `auto` falls back to the default org when the preferred org refuses the model (#9)
9
+ - `proxy`: `CLAUDE_ORG_STRATEGY=least-utilized` sends each request to the usable org with the lowest 5h utilization it has reported (orgs not yet measured are tried first). Unset keeps the priority order; the launch line and the log now show the strategy (#7)
10
+ - Docs: proxy mode now explains that Agent-tool subagents go through the same proxy and what that implies (#16)
11
+
12
+ ## 0.2.2 — 2026-10-10
13
+
14
+ - Development: `npm run release -- <patch|minor|major|X.Y.Z>` runs the whole release (checks, tests, CHANGELOG, version, commit, tag, push, npm publish, registry check)
15
+
16
+ ## 0.2.1 — 2026-10-10
17
+
18
+ - `proxy`: a 429 is now classified before failing over (#8). Only a rejected 5h or 7d window takes the whole org out. Any other 429 (a used-up per-model allowance, a model that needs usage credits, a per-minute limit) takes the org out for that model only, for at most 10 minutes or the `retry-after` time, so other models keep using the org
19
+ - `proxy`: the log now shows the reason of each 429 (`error.type: message`, decoded from a brotli/gzip/deflate body) and the rate-limit claim, and says to switch the model when every org refused the same model
20
+ - Windows: launch `claude` without the Node DEP0190 deprecation warning (shell args passed as one quoted command string) (#10)
21
+
3
22
  ## 0.2.0 — 2026-10-03
4
23
 
5
24
  - Add `ccorg` as a short command alias for `claude-org` (same binary)
package/README.md CHANGED
@@ -57,13 +57,22 @@ Repeat steps 1–2 **once per org**:
57
57
  | `claude-org proxy [claude args...]` | Launch via a local proxy: a **running** session fails over to another org the moment the 5h limit hits. Needs 2+ org tokens (incl. one for the default side). |
58
58
  | `claude-org auto [claude args...]` | Launch on the preferred org if usable, else on the default org. Decided once at launch. |
59
59
  | `claude-org <name> [claude args...]` | Launch with that org's token, e.g. `claude-org work --resume`. |
60
- | `claude-org probe [<name>]` | Print the org's 5h window state on one line (`status= 5h_util= 5h_reset= 5h_reset_local=`). Exit 0 when `allowed`. |
60
+ | `claude-org probe [<name>] [--model <id>]` | Print the org's 5h window state on one line (`status= 5h_util= 5h_reset= 5h_reset_local=`). Exit 0 when `allowed`. With `--model`, probe with that model and append `model= model_status=` (see below). |
61
61
  | `claude-org watch` | Poll the preferred org until its window recovers, then notify (terminal + optional `CLAUDE_ORG_NOTIFY_CMD`). |
62
- | `claude-org status` | `claude auth status` for every saved token + the default login. |
62
+ | `claude-org status [--model <id>]` | `claude auth status` and `probe` for every saved token + the default login. |
63
63
  | `claude-org list` | List saved org names. |
64
64
 
65
65
  `auto` and `<name>` only read env at launch — they cannot switch a running session. Settings, plugins, and session history live under `~/.claude/` and are shared across orgs, so `claude-org auto --resume` continues a session started under the other org.
66
66
 
67
+ **Checking a model, not just the window**: `probe` and `status` use Haiku by default, but an org can show `status=allowed` and still refuse another model (for example one that needs usage credits). Pass the model your session uses:
68
+
69
+ ```sh
70
+ claude-org probe work --model claude-fable-5-1
71
+ # status=allowed 5h_util=0.34 ... model=claude-fable-5-1 model_status=unavailable model_error=rate_limit_error
72
+ ```
73
+
74
+ `model_status` is `available` (2xx), `unavailable` (refused while the 5h/7d windows are not rejected; exit 1) or `unknown` (no response, bad token or permission, timeout, server error, or a rejected window; the exit code then follows `status` alone). Setting `CLAUDE_ORG_PROBE_MODEL` applies the same check to `probe`, `status` and `auto`; `auto` then launches on the default org when the preferred org refuses that model.
75
+
67
76
  The **preferred org** (used by `auto`, `watch`, and as `proxy`'s first candidate) resolves in this order: `CLAUDE_ORG_PREFER` -> first entry of `CLAUDE_ORG_ORDER` -> first `.token` file alphabetically.
68
77
 
69
78
  ## proxy mode
@@ -77,13 +86,29 @@ claude-org proxy --resume
77
86
  3. The proxy replaces the `Authorization` header with a real org token and forwards to `api.anthropic.com`. On a 429 or `anthropic-ratelimit-unified-5h-status: rejected` it marks that org limited until `5h-reset` and **resends the same request** with the next org's token.
78
87
  4. Claude Code only ever sees the successful response — the org switched mid-session. SSE streaming is piped through untouched.
79
88
 
80
- One log line per request (`via <org> -> <status>`) goes to a temp file printed at launch (`-> proxy 127.0.0.1:<port> (order: ..., log: ...)`).
89
+ One log line per request (`via <org> model=<model> -> <status>`) goes to a temp file printed at launch (`-> proxy 127.0.0.1:<port> (order: ..., strategy: ..., log: ...)`). The prompt and the tokens are never logged.
90
+
91
+ **Spreading load by utilization**: with `CLAUDE_ORG_STRATEGY=least-utilized`, the proxy remembers the `5h-utilization` each org last reported and sends each request to the usable org with the lowest value. An org that has not reported yet is tried first (in priority order) so it gets measured once; one that answers without a utilization goes after the measured ones. A value is forgotten when that org's 5h window resets. Rate-limited orgs are still skipped. Utilization is a share of each org's own plan, not an absolute amount, so a larger plan is picked more often; if you want one org's allowance used up first (e.g. your company's), keep the default and set `CLAUDE_ORG_ORDER` instead.
92
+
93
+ **Which org is in use right now**: after each model request the proxy writes `{"org", "model", "status", "at"}` as JSON (`org` is `null` with `status` 503 when no org could serve) to `state.json` in the same temp directory as the log. Its path is exported to the `claude` process as `CLAUDE_ORG_STATE_FILE`, so a statusline command or hook can show it, e.g.:
94
+
95
+ ```sh
96
+ [ -n "$CLAUDE_ORG_STATE_FILE" ] && [ -f "$CLAUDE_ORG_STATE_FILE" ] && node -p "require(process.env.CLAUDE_ORG_STATE_FILE).org"
97
+ ```
81
98
 
82
99
  **Limitations of proxy mode**
83
100
 
84
101
  - claude.ai connector MCPs (Gmail / Drive / Calendar / Notion / Slack …) and Remote Control are **unavailable**: `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as external-token auth, so `mcp__claude_ai_*` tools do not appear. Local/plugin MCPs work. Open connectors in a plain `claude --resume` session — history is shared.
85
102
  - Each proxy serves one session and dies when `claude` exits.
86
103
 
104
+ **Subagents**
105
+
106
+ - Subagents started with Claude Code's Agent tool run in the same process as the parent session and share its `ANTHROPIC_BASE_URL`, so they go through the same proxy with no setup and use the same orgs' limits. Their requests appear in the proxy log with their own `model=` (e.g. a Haiku subagent).
107
+ - Not covered: a `claude` started as a separate process (it needs its own `claude-org proxy`), and agents that run in the cloud (e.g. `isolation: remote`).
108
+ - The connector MCP limitation above applies to subagents too.
109
+ - When an org's 5h or 7d window is rejected, the org is taken out for every model, so subagents also move to the next org until the window resets. A 429 for one model only (#8) moves that model only; other models, including those of subagents, stay on the org.
110
+ - Work routed to a Team / Enterprise org (e.g. your company's) counts toward that org's usage, which its admins can see.
111
+
87
112
  ### Prompt cache & tool search
88
113
 
89
114
  A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as API-key auth, which changes two defaults:
@@ -105,11 +130,14 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
105
130
  |---|---|---|
106
131
  | `CLAUDE_ORG_PREFER` | (unset) | Preferred org for `auto` / `watch` / `proxy` |
107
132
  | `CLAUDE_ORG_ORDER` | (unset) | Comma-separated org priority for `proxy`; unlisted orgs follow alphabetically |
133
+ | `CLAUDE_ORG_STRATEGY` | (unset: priority order) | `least-utilized`: `proxy` sends each request to the usable org with the lowest 5h utilization (see proxy mode) |
134
+ | `CLAUDE_ORG_PROBE_MODEL` | (unset: Haiku) | Model used by `probe` / `status` / `auto` to check the org; adds `model_status=` to the probe line |
108
135
  | `CLAUDE_ORG_TOKEN_DIR` | `~/.claude/org-tokens` | Where `<name>.token` files live |
109
136
  | `CLAUDE_ORG_NOTIFY_CMD` | (unset) | `watch` notification command, run as `<cmd> "<title>" "<message>"` via the OS shell |
110
137
  | `CLAUDE_ORG_WATCH_MAX_SLEEP` | `18000` | Max seconds between `watch` re-checks (values < 60 also lower the 60s minimum — for tests) |
111
138
  | `CLAUDE_ORG_UPSTREAM` | `https://api.anthropic.com` | API base URL for `probe` and `proxy` (stub upstream in tests) |
112
139
  | `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect connector-less sessions |
140
+ | `CLAUDE_ORG_STATE_FILE` | set when launched via `proxy` | Path of the JSON file holding the org and model of the last request (read it from a statusline or hook) |
113
141
  | `CLAUDE_ORG_FORCE_PROBE` | (unset) | Test seam: `allowed` / `rejected[:<epoch>]` / `rejected_until:<epoch>` fakes `probe` results without API calls |
114
142
  | `CLAUDE_ORG_REJECT_FILE` | (unset) | Test seam: orgs listed comma-separated in this file are treated as rate-limited by `proxy` |
115
143
 
@@ -130,6 +158,20 @@ npm test # node:test suite, no real API calls
130
158
  npm pack --dry-run # inspect the published file set
131
159
  ```
132
160
 
161
+ ### Releasing
162
+
163
+ Write the changes under `## Unreleased` in `CHANGELOG.md`, then on an up-to-date `main`:
164
+
165
+ ```sh
166
+ npm login # once per machine
167
+ npm run release -- patch --dry-run # checks + tests only, changes nothing
168
+ npm run release -- patch # or minor / major / X.Y.Z
169
+ ```
170
+
171
+ The script refuses to run off `main`, with uncommitted tracked changes, out of sync with `origin/main`, with an existing tag or registry version, with files npm would publish that git does not track, or without npm login. It then runs the tests, renames `## Unreleased` to the new version heading, bumps `package.json`, commits `chore: release vX.Y.Z`, tags, pushes the commit and tag together (atomic), runs `npm publish` and waits until the registry serves the new version. `--no-publish` stops after the push.
172
+
173
+ Users update with `npm install -g claude-org@latest`.
174
+
133
175
  ## License
134
176
 
135
177
  MIT
package/bin/claude-org.js CHANGED
@@ -10,6 +10,23 @@ const { statusAll } = require('../lib/status');
10
10
  const { watch } = require('../lib/watch');
11
11
  const { proxyLaunch } = require('../lib/proxy');
12
12
 
13
+ // `--model <id>` / `--model=<id>` for probe and status. Returns the model
14
+ // ('' when absent) and the remaining args. A missing value is an error rather
15
+ // than a silent fall back to the default model.
16
+ function takeModel(args) {
17
+ const rest = [];
18
+ let model = '';
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i] === '--model' || args[i].startsWith('--model=')) {
21
+ model = args[i] === '--model' ? args[++i] : args[i].slice(8);
22
+ if (!model || model.startsWith('-')) throw new Error('--model needs a model id, e.g. --model claude-fable-5-1');
23
+ } else {
24
+ rest.push(args[i]);
25
+ }
26
+ }
27
+ return { model, rest };
28
+ }
29
+
13
30
  async function main() {
14
31
  const [cmd, ...rest] = process.argv.slice(2);
15
32
  switch (cmd) {
@@ -23,11 +40,12 @@ async function main() {
23
40
  for (const o of listOrgs()) console.log(o);
24
41
  return 0;
25
42
  case 'status':
26
- return statusAll();
43
+ return statusAll(takeModel(rest).model || undefined);
27
44
  case 'probe': {
28
- const r = await probe(rest[0] || resolvePrefer());
45
+ const { model, rest: names } = takeModel(rest);
46
+ const r = await probe(names[0] || resolvePrefer(), model || undefined);
29
47
  if (r.line) console.log(r.line);
30
- return r.allowed ? 0 : 1;
48
+ return r.usable ? 0 : 1;
31
49
  }
32
50
  case 'pick':
33
51
  console.log(await pick());
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ const { spawn } = require('child_process');
3
+
4
+ // DEP0190: passing an args array with shell:true is deprecated (args are
5
+ // concatenated unescaped). On Windows a shell is still needed to resolve
6
+ // claude.cmd, so build a single quoted command string instead.
7
+ const q = s => `"${String(s).replace(/(["\\$`])/g, '\\$1')}"`;
8
+
9
+ function spawnClaude(args, opts = {}) {
10
+ if (process.platform === 'win32') {
11
+ return spawn(['claude', ...args.map(q)].join(' '), { ...opts, shell: true });
12
+ }
13
+ return spawn('claude', args, opts);
14
+ }
15
+
16
+ module.exports = { spawnClaude, q };
package/lib/launch.js CHANGED
@@ -1,14 +1,13 @@
1
1
  'use strict';
2
- const { spawn } = require('child_process');
2
+ const { spawnClaude } = require('./claude-spawn');
3
3
  const { readToken } = require('./config');
4
4
 
5
5
  // Spawn claude with inherited stdio and extra env. Resolves to the child's
6
6
  // exit code (128+n when killed by signal n, like a shell would report).
7
7
  function runClaude(args, envExtra = {}) {
8
8
  return new Promise(resolve => {
9
- const child = spawn('claude', args, {
9
+ const child = spawnClaude(args, {
10
10
  stdio: 'inherit',
11
- shell: process.platform === 'win32', // resolves claude.cmd on Windows
12
11
  env: { ...process.env, ...envExtra },
13
12
  });
14
13
  child.on('error', e => {
package/lib/pick.js CHANGED
@@ -3,18 +3,23 @@ const { resolvePrefer } = require('./config');
3
3
  const { probe } = require('./probe');
4
4
 
5
5
  // pick(): which org `auto` should launch on. Returns 'default' when the
6
- // preferred org is over its 5h limit (or its token is missing). The decision
7
- // reason goes to stderr as a single `-> ...` line.
6
+ // preferred org is over its 5h limit (or its token is missing), or refused the
7
+ // model set in CLAUDE_ORG_PROBE_MODEL. The decision reason goes to stderr as a
8
+ // single `-> ...` line.
8
9
  async function pick() {
9
10
  const prefer = resolvePrefer();
10
11
  const r = await probe(prefer);
11
- if (r.allowed) {
12
+ if (r.usable) {
12
13
  let util = r.util;
13
14
  if (util !== '?') util = `${Math.trunc(Number(util) * 100)}%`;
14
15
  console.error(`-> ${prefer} (5h ${util} used)`);
15
16
  return prefer;
16
17
  }
17
- console.error(`-> default (${prefer} is ${r.status}, resets ${r.resetLocal})`);
18
+ if (r.allowed) {
19
+ console.error(`-> default (${prefer} refused model ${r.model})`);
20
+ } else {
21
+ console.error(`-> default (${prefer} is ${r.status}, resets ${r.resetLocal})`);
22
+ }
18
23
  return 'default';
19
24
  }
20
25
 
package/lib/probe.js CHANGED
@@ -2,18 +2,78 @@
2
2
  const { readToken } = require('./config');
3
3
  const { epochToHHMM } = require('./time');
4
4
 
5
- // probe(name): send a 1-token inference request and read the unified 5h
6
- // rate-limit headers. Returns { line, status, util, reset, resetLocal, allowed }.
7
- // `line` is the canonical one-line output:
5
+ const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
6
+
7
+ // Whether the org served the probed model. The 5h window is shared by every
8
+ // model, but a model can still be refused while it reads "allowed" (it needs
9
+ // usage credits, or its own weekly allowance is used up), so this is judged
10
+ // from the HTTP status, not from the window headers.
11
+ // available 2xx
12
+ // unavailable any other 4xx, while neither the 5h nor the 7d window is
13
+ // rejected
14
+ // unknown no response, 401/403 (token or org permission), 408, 5xx, or
15
+ // a rejected window (which hides whether the model itself
16
+ // would be served)
17
+ function judgeModel(res) {
18
+ if (!res) return 'unknown';
19
+ if (res.ok) return 'available';
20
+ const h = name => res.headers.get(`anthropic-ratelimit-unified-${name}`);
21
+ if (h('5h-status') === 'rejected' || h('7d-status') === 'rejected') return 'unknown';
22
+ if ([401, 403, 408].includes(res.status) || res.status >= 500 || res.status < 400) return 'unknown';
23
+ return 'unavailable';
24
+ }
25
+
26
+ // One 1-token request. Returns { res, errorType }: res is null when no response
27
+ // came; errorType is error.type of a non-2xx JSON body ('' otherwise).
28
+ async function send(tok, model) {
29
+ const base = (process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com').replace(/\/+$/, '');
30
+ try {
31
+ const res = await fetch(`${base}/v1/messages`, {
32
+ method: 'POST',
33
+ headers: {
34
+ authorization: `Bearer ${tok}`,
35
+ 'anthropic-beta': 'oauth-2025-04-20',
36
+ 'anthropic-version': '2023-06-01',
37
+ 'content-type': 'application/json',
38
+ },
39
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
40
+ signal: AbortSignal.timeout(20000),
41
+ });
42
+ let errorType = '';
43
+ if (res.ok) {
44
+ res.body && res.body.cancel().catch(() => {});
45
+ } else {
46
+ try {
47
+ errorType = String(JSON.parse(await res.text()).error.type || '').replace(/[^\w.-]/g, '_').slice(0, 60);
48
+ } catch {
49
+ errorType = ''; // not a JSON error body: the HTTP status still decides model_status
50
+ }
51
+ }
52
+ return { res, errorType };
53
+ } catch {
54
+ return { res: null, errorType: '' };
55
+ }
56
+ }
57
+
58
+ // probe(name, model): send a 1-token inference request and read the unified
59
+ // 5h rate-limit headers. Returns { line, status, util, reset, resetLocal,
60
+ // allowed, model, modelStatus, usable }. `line` is the canonical one-line output:
8
61
  // status=<allowed|rejected|unknown> 5h_util=<0.xx|?> 5h_reset=<epoch|?> 5h_reset_local=<HH:MM|?>
62
+ // When a model is given (argument, else CLAUDE_ORG_PROBE_MODEL), the request
63
+ // uses it and the line ends with ` model=<id> model_status=<see judgeModel>`,
64
+ // plus ` model_error=<error.type>` when the model was refused.
65
+ // Without one the request uses Haiku and the line is unchanged. `usable` is
66
+ // "allowed and the model was not refused".
9
67
  //
10
68
  // Test seam: CLAUDE_ORG_FORCE_PROBE = allowed | rejected[:<epoch>] |
11
69
  // rejected_until:<epoch> (rejected_until flips to allowed once <epoch> passes).
12
70
  // When forced, the token file is not required and no request is sent.
13
71
  // CLAUDE_ORG_UPSTREAM overrides the API base URL (stub upstream in tests).
14
- async function probe(name) {
72
+ async function probe(name, model = process.env.CLAUDE_ORG_PROBE_MODEL || '') {
15
73
  const forced = process.env.CLAUDE_ORG_FORCE_PROBE;
16
74
  let status, util, reset;
75
+ let modelStatus = 'unknown';
76
+ let modelError = '';
17
77
  if (forced) {
18
78
  const idx = forced.indexOf(':');
19
79
  status = idx === -1 ? forced : forced.slice(0, idx);
@@ -24,29 +84,26 @@ async function probe(name) {
24
84
  util = '?';
25
85
  } else {
26
86
  const tok = readToken(name);
27
- if (tok === null) return { line: null, status: 'unknown', util: '?', reset: '?', resetLocal: '?', allowed: false };
28
- const base = (process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com').replace(/\/+$/, '');
29
- let headers = null;
30
- try {
31
- const res = await fetch(`${base}/v1/messages`, {
32
- method: 'POST',
33
- headers: {
34
- authorization: `Bearer ${tok}`,
35
- 'anthropic-beta': 'oauth-2025-04-20',
36
- 'anthropic-version': '2023-06-01',
37
- 'content-type': 'application/json',
38
- },
39
- body: JSON.stringify({
40
- model: 'claude-haiku-4-5-20251001',
41
- max_tokens: 1,
42
- messages: [{ role: 'user', content: 'hi' }],
43
- }),
44
- signal: AbortSignal.timeout(20000),
45
- });
46
- headers = res.headers;
47
- res.body && res.body.cancel().catch(() => {});
48
- } catch {
49
- headers = null;
87
+ if (tok === null) {
88
+ return { line: null, status: 'unknown', util: '?', reset: '?', resetLocal: '?', allowed: false, model, modelStatus, usable: false };
89
+ }
90
+ const first = await send(tok, model || DEFAULT_MODEL);
91
+ let headers = first.res && first.res.headers;
92
+ modelStatus = judgeModel(first.res);
93
+ if (modelStatus === 'unavailable') modelError = first.errorType;
94
+ // A refusal for the model itself carried no window headers on 2026-10-10
95
+ // (Fable 5.1 on an org without usage credits), so the 5h state is read
96
+ // from a second request with the default model.
97
+ if (model && modelStatus === 'unavailable' && !headers.get('anthropic-ratelimit-unified-5h-status')) {
98
+ const second = await send(tok, DEFAULT_MODEL);
99
+ headers = second.res && second.res.headers;
100
+ // A rejected window there means the first 429 may have been the window,
101
+ // not the model, so the model cannot be judged.
102
+ const w = name => headers && headers.get(`anthropic-ratelimit-unified-${name}`);
103
+ if (w('5h-status') === 'rejected' || w('7d-status') === 'rejected') {
104
+ modelStatus = 'unknown';
105
+ modelError = '';
106
+ }
50
107
  }
51
108
  status = (headers && (headers.get('anthropic-ratelimit-unified-5h-status')
52
109
  || headers.get('anthropic-ratelimit-unified-status'))) || 'unknown';
@@ -54,8 +111,12 @@ async function probe(name) {
54
111
  reset = (headers && headers.get('anthropic-ratelimit-unified-5h-reset')) || '?';
55
112
  }
56
113
  const resetLocal = reset !== '?' ? epochToHHMM(reset) : '?';
57
- const line = `status=${status} 5h_util=${util} 5h_reset=${reset} 5h_reset_local=${resetLocal}`;
58
- return { line, status, util, reset, resetLocal, allowed: status === 'allowed' };
114
+ let line = `status=${status} 5h_util=${util} 5h_reset=${reset} 5h_reset_local=${resetLocal}`;
115
+ // The model is user input; keep the key=value line parseable.
116
+ if (model) line += ` model=${model.replace(/[^\w.:@/-]/g, '_')} model_status=${modelStatus}${modelError ? ` model_error=${modelError}` : ''}`;
117
+ const allowed = status === 'allowed';
118
+ const usable = allowed && !(model && modelStatus === 'unavailable');
119
+ return { line, status, util, reset, resetLocal, allowed, model, modelStatus, usable };
59
120
  }
60
121
 
61
122
  module.exports = { probe };
package/lib/proxy.js CHANGED
@@ -6,6 +6,12 @@
6
6
  // with the next org's token. A running Claude Code session never sees the 429,
7
7
  // so the org switches without a restart.
8
8
  //
9
+ // Not every 429 means the org is used up. A model can have its own limit (for
10
+ // example a per-model weekly allowance, or usage credits that the org has not
11
+ // enabled) while the 5h and 7d windows are still "allowed" and other models
12
+ // work. Such a 429 only takes that org out for that model; the org keeps
13
+ // serving other models.
14
+ //
9
15
  // Why a proxy instead of apiKeyHelper: an OAuth long-lived token is accepted as
10
16
  // `Authorization: Bearer` alone, but if `x-api-key` is also present it is checked
11
17
  // first and returns 401. The proxy drops x-api-key and sends Bearer only.
@@ -15,14 +21,100 @@
15
21
  // "listening on" stderr line)
16
22
  // CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
17
23
  // CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
24
+ // CLAUDE_ORG_STRATEGY "least-utilized": prefer the usable org with the
25
+ // lowest 5h utilization seen so far (default: ORDER)
18
26
  // CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com)
19
27
  // CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
20
28
  // are treated as rate-limited
29
+ // CLAUDE_ORG_STATE_FILE where the org and model of the last /v1/messages
30
+ // response are written as JSON (unset: not written)
21
31
  const http = require('http');
22
32
  const https = require('https');
23
33
  const fs = require('fs');
24
34
  const path = require('path');
25
35
  const os = require('os');
36
+ const zlib = require('zlib');
37
+
38
+ // How long an org stays out when it is org-wide limited but sent no reset time,
39
+ // and the longest a model-specific refusal keeps an (org, model) pair out.
40
+ const ORG_REJECT_FALLBACK_MS = 10 * 60 * 1000;
41
+ const MODEL_REJECT_MAX_MS = 10 * 60 * 1000;
42
+ // A real 429 body is well under 1 KiB. These caps keep a misbehaving upstream
43
+ // from exhausting memory: the raw body read, and the decompressed copy.
44
+ const ERROR_BODY_MAX_BYTES = 1024 * 1024;
45
+ const ERROR_DECODED_MAX_BYTES = 64 * 1024;
46
+
47
+ // The "model" field of a /v1/messages request body, or '?' when the body is
48
+ // not JSON (the proxy must still forward it). Used as is as the quarantine
49
+ // key, so two different model names never share a key.
50
+ function requestModel(body) {
51
+ try {
52
+ const m = JSON.parse(body.toString('utf8')).model;
53
+ return typeof m === 'string' && m ? m : '?';
54
+ } catch {
55
+ return '?';
56
+ }
57
+ }
58
+
59
+ // The model name for the log: client input, so limited to safe characters and
60
+ // cut short.
61
+ function logModel(model) {
62
+ return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
63
+ }
64
+
65
+ // Milliseconds from a Retry-After header, which is either delay-seconds or an
66
+ // HTTP-date. NaN when absent or unparsable.
67
+ function retryAfterMs(value) {
68
+ if (value === undefined || value === '') return NaN;
69
+ const secs = Number(value);
70
+ if (Number.isFinite(secs)) return secs >= 0 ? secs * 1000 : NaN;
71
+ const at = Date.parse(value);
72
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : NaN;
73
+ }
74
+
75
+ function readBody(res, maxBytes) {
76
+ return new Promise((resolve, reject) => {
77
+ const chunks = [];
78
+ let size = 0;
79
+ res.on('data', c => {
80
+ size += c.length;
81
+ if (size > maxBytes) {
82
+ res.destroy();
83
+ reject(new Error(`429 body larger than ${maxBytes} bytes`));
84
+ return;
85
+ }
86
+ chunks.push(c);
87
+ });
88
+ res.on('end', () => resolve(Buffer.concat(chunks)));
89
+ res.on('error', reject);
90
+ });
91
+ }
92
+
93
+ // The API compresses error bodies (brotli was observed on 2026-10-10) when the
94
+ // client sent accept-encoding. Decode a copy for the log; the client still
95
+ // gets the original bytes with the original content-encoding header.
96
+ // maxOutputLength makes a decompression bomb throw instead of filling memory.
97
+ function decodeBody(buf, encoding) {
98
+ const opts = { maxOutputLength: ERROR_DECODED_MAX_BYTES };
99
+ switch ((encoding || '').trim().toLowerCase()) {
100
+ case 'br': return zlib.brotliDecompressSync(buf, opts);
101
+ case 'gzip': case 'x-gzip': return zlib.gunzipSync(buf, opts);
102
+ case 'deflate': return zlib.inflateSync(buf, opts);
103
+ default: return buf;
104
+ }
105
+ }
106
+
107
+ // "error.type: error.message" from an API error body, for the log line. The
108
+ // message is cut short; the full body still reaches the client.
109
+ function describeError(buf, encoding) {
110
+ try {
111
+ const e = JSON.parse(decodeBody(buf, encoding).toString('utf8')).error || {};
112
+ const msg = String(e.message || '').replace(/\s+/g, ' ').slice(0, 160);
113
+ return msg ? `${e.type || 'unknown_error'}: ${msg}` : (e.type || 'unknown_error');
114
+ } catch {
115
+ return 'non-JSON 429 body';
116
+ }
117
+ }
26
118
 
27
119
  function createProxy(opts = {}) {
28
120
  const TOKEN_DIR = opts.tokenDir || process.env.CLAUDE_ORG_TOKEN_DIR
@@ -31,12 +123,16 @@ function createProxy(opts = {}) {
31
123
  || (process.env.CLAUDE_ORG_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);
32
124
  const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com');
33
125
  const REJECT_FILE = 'rejectFile' in opts ? opts.rejectFile : (process.env.CLAUDE_ORG_REJECT_FILE || '');
126
+ const STATE_FILE = 'stateFile' in opts ? opts.stateFile : (process.env.CLAUDE_ORG_STATE_FILE || '');
127
+ const STRATEGY = 'strategy' in opts ? opts.strategy : (process.env.CLAUDE_ORG_STRATEGY || '');
34
128
  const out = opts.log || process.stderr;
35
129
  const transport = UPSTREAM.protocol === 'http:' ? http : https;
36
130
 
37
131
  function log(...a) {
38
132
  out.write(`[claude-org-proxy ${new Date().toISOString().slice(11, 19)}] ${a.join(' ')}\n`);
39
133
  }
134
+ const leastUtilized = STRATEGY === 'least-utilized';
135
+ if (STRATEGY && !leastUtilized) log(`unknown CLAUDE_ORG_STRATEGY "${STRATEGY}" -> using the static order`);
40
136
 
41
137
  function listOrgs() {
42
138
  const files = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6)).sort();
@@ -59,6 +155,15 @@ function createProxy(opts = {}) {
59
155
  return false;
60
156
  }
61
157
  }
158
+ // Per-(org, model) memory for model-specific refusals: "org|model" -> epoch (ms).
159
+ const modelRejectedUntil = new Map();
160
+ function isModelRejected(name, model) {
161
+ const key = `${name}|${model}`;
162
+ const until = modelRejectedUntil.get(key);
163
+ if (until === undefined) return false;
164
+ if (Date.now() >= until) { modelRejectedUntil.delete(key); return false; }
165
+ return true;
166
+ }
62
167
  function isRejected(name) {
63
168
  if (fileRejected(name)) return true;
64
169
  const until = rejectedUntil.get(name);
@@ -66,11 +171,63 @@ function createProxy(opts = {}) {
66
171
  if (Date.now() >= until) { rejectedUntil.delete(name); return false; }
67
172
  return true;
68
173
  }
174
+ // Latest 5h utilization (0..1) each org reported, valid until that window's
175
+ // reset: name -> { util, until (epoch ms) }. An org that is not picked would
176
+ // otherwise keep its old high value after its window resets.
177
+ const utilization = new Map();
178
+ // Orgs that answered without a utilization header, so they are not put first
179
+ // again and again as "not measured yet".
180
+ const answeredUnmeasured = new Set();
181
+ function utilOf(name) {
182
+ const e = utilization.get(name);
183
+ if (e === undefined) return undefined;
184
+ if (Date.now() >= e.until) { utilization.delete(name); return undefined; }
185
+ return e.util;
186
+ }
187
+ function recordUtil(name, util, reset) {
188
+ const until = Number.isFinite(reset) ? reset * 1000 : Infinity;
189
+ utilization.set(name, { util, until });
190
+ answeredUnmeasured.delete(name);
191
+ }
69
192
  // Usable orgs first, in priority order; rate-limited orgs are moved to the
70
193
  // tail (kept as a last resort when everything is limited).
71
- function pickOrder() {
194
+ // least-utilized: among the usable orgs, those that have not been measured
195
+ // come first (in priority order) so that each is measured once, then the
196
+ // rest by utilization, lowest first, then those that answered without a
197
+ // utilization. Utilization is a share of each org's own plan, not an
198
+ // absolute amount, so a larger plan is picked more often.
199
+ function pickOrder(model) {
72
200
  const orgs = listOrgs();
73
- return orgs.filter(o => !isRejected(o)).concat(orgs.filter(o => isRejected(o)));
201
+ const blocked = o => isRejected(o) || isModelRejected(o, model);
202
+ let usable = orgs.filter(o => !blocked(o));
203
+ if (leastUtilized) {
204
+ const known = usable.filter(o => utilOf(o) !== undefined).sort((a, b) => utilOf(a) - utilOf(b));
205
+ const unknown = usable.filter(o => utilOf(o) === undefined);
206
+ usable = unknown.filter(o => !answeredUnmeasured.has(o))
207
+ .concat(known, unknown.filter(o => answeredUnmeasured.has(o)));
208
+ }
209
+ return usable.concat(orgs.filter(blocked));
210
+ }
211
+
212
+ // Claude Code does not show the x-claude-org response header, so the org in
213
+ // use is written to a file that a statusline or hook can read. Written via a
214
+ // rename so a reader never sees a half-written file. No prompt or token goes in.
215
+ // The model is stored in full (JSON escapes it), unlike the cut log copy, so
216
+ // two long model names stay distinguishable.
217
+ // On Windows the rename fails while a reader holds the file open; that write
218
+ // is skipped and only the first failure is logged, to keep the log readable.
219
+ let stateWriteFailed = false;
220
+ function writeState(org, model, status) {
221
+ if (!STATE_FILE) return;
222
+ const tmp = `${STATE_FILE}.${process.pid}.tmp`;
223
+ try {
224
+ fs.writeFileSync(tmp, JSON.stringify({ org, model, status, at: new Date().toISOString() }) + '\n');
225
+ fs.renameSync(tmp, STATE_FILE);
226
+ } catch (e) {
227
+ try { fs.unlinkSync(tmp); } catch { /* the tmp file was never created */ }
228
+ if (!stateWriteFailed) log(`cannot write state file ${STATE_FILE}: ${e.message} (further failures are not logged)`);
229
+ stateWriteFailed = true;
230
+ }
74
231
  }
75
232
 
76
233
  function forward(org, method, urlPath, headers, body) {
@@ -99,8 +256,10 @@ function createProxy(opts = {}) {
99
256
  clientReq.on('end', async () => {
100
257
  const body = Buffer.concat(chunks);
101
258
  const isModelReq = clientReq.url.startsWith('/v1/messages');
102
- const order = pickOrder();
259
+ const model = isModelReq ? requestModel(body) : '';
260
+ const order = pickOrder(model);
103
261
  let attempt = 0;
262
+ let modelRefusals = 0;
104
263
  for (const org of order) {
105
264
  attempt++;
106
265
  let up;
@@ -110,29 +269,86 @@ function createProxy(opts = {}) {
110
269
  log(`upstream error via ${org}: ${e.message}`);
111
270
  continue;
112
271
  }
113
- const status5h = up.headers['anthropic-ratelimit-unified-5h-status'];
114
- const reset5h = Number(up.headers['anthropic-ratelimit-unified-5h-reset']);
115
- const limited = up.statusCode === 429 || status5h === 'rejected';
116
- if (limited && isModelReq && attempt < order.length) {
117
- rejectedUntil.set(org, Number.isFinite(reset5h) ? reset5h * 1000 : Date.now() + 10 * 60 * 1000);
118
- log(`${org} is rate-limited (HTTP ${up.statusCode}, 5h=${status5h ?? '?'}) -> retrying with next org`);
119
- up.resume(); // discard the response body and try the next org
272
+ const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
273
+ const status5h = hdr('5h-status');
274
+ const status7d = hdr('7d-status');
275
+ const claim = hdr('representative-claim');
276
+ const util5h = Number(hdr('5h-utilization'));
277
+ if (isModelReq && hdr('5h-utilization') !== undefined && Number.isFinite(util5h)) {
278
+ recordUtil(org, util5h, Number(hdr('5h-reset')));
279
+ } else if (isModelReq) {
280
+ answeredUnmeasured.add(org);
281
+ }
282
+ // The 5h and 7d windows cover every model of the org, so only those make
283
+ // the org as a whole unusable. Other refusals are per model: a used-up
284
+ // per-model weekly allowance (claim=seven_day_overage_included) or a model
285
+ // that needs usage credits (429 with no window headers). The overall
286
+ // "unified-status" header is not used: it also reads "rejected" for those.
287
+ const orgWide = status5h === 'rejected' || status7d === 'rejected';
288
+ const limited = up.statusCode === 429 || orgWide;
289
+ const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
290
+ // A 429 body is a small JSON error; buffer it to log the reason and,
291
+ // if no org is left, to hand it to the client unchanged.
292
+ let errBody = null;
293
+ let reason = '';
294
+ if (isModelReq && up.statusCode === 429) {
295
+ try {
296
+ errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
297
+ } catch (e) {
298
+ log(`upstream error via ${org} while reading 429 body: ${e.message}`);
299
+ continue;
300
+ }
301
+ reason = describeError(errBody, up.headers['content-encoding']);
302
+ }
303
+ const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
304
+ if (modelRefused) {
305
+ // retry-after is honoured when shorter (a transient per-minute limit
306
+ // must not keep the preferred org out for long); a long one (a weekly
307
+ // per-model allowance) is capped so the org is re-checked now and then.
308
+ const ra = retryAfterMs(up.headers['retry-after']);
309
+ const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
310
+ modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
311
+ }
312
+ const isLast = attempt >= order.length;
313
+ if (limited && isModelReq && !isLast) {
314
+ if (orgWide) {
315
+ // When both windows are rejected the org is back only after the later reset.
316
+ const resets = [['5h', status5h], ['7d', status7d]]
317
+ .filter(([, s]) => s === 'rejected')
318
+ .map(([w]) => Number(hdr(`${w}-reset`)))
319
+ .filter(Number.isFinite);
320
+ rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
321
+ log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
322
+ } else {
323
+ modelRefusals++;
324
+ log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
325
+ }
326
+ if (!errBody) up.resume(); // discard the response body and try the next org
120
327
  continue;
121
328
  }
122
329
  if (isModelReq) {
123
- const util = up.headers['anthropic-ratelimit-unified-5h-utilization'];
124
- log(`${clientReq.method} ${clientReq.url} via ${org} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})`);
330
+ const util = hdr('5h-utilization');
331
+ log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
332
+ writeState(org, model, up.statusCode);
333
+ if (modelRefused && modelRefusals === attempt - 1) {
334
+ log(`all orgs rejected model ${logModel(model)} -> switch the model (e.g. /model) or wait for that model's limit to reset; the org tokens are not the cause`);
335
+ }
125
336
  }
126
337
  clientRes.writeHead(up.statusCode, { ...up.headers, 'x-claude-org': org });
127
- up.pipe(clientRes); // SSE streaming is piped through untouched
338
+ if (errBody) {
339
+ clientRes.end(errBody);
340
+ } else {
341
+ up.pipe(clientRes); // SSE streaming is piped through untouched
342
+ }
128
343
  return;
129
344
  }
345
+ if (isModelReq) writeState(null, model, 503); // no org served it
130
346
  clientRes.writeHead(503, { 'content-type': 'application/json' });
131
347
  clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message: 'all orgs rate-limited or unreachable' } }));
132
348
  });
133
349
  });
134
350
 
135
- return { server, listOrgs, log, upstream: UPSTREAM };
351
+ return { server, listOrgs, log, upstream: UPSTREAM, strategy: leastUtilized ? 'least-utilized' : 'order' };
136
352
  }
137
353
 
138
354
  // `claude-org proxy [claude args...]`: start an in-process proxy on a random
@@ -150,15 +366,17 @@ async function proxyLaunch(args) {
150
366
  }
151
367
 
152
368
  const order = process.env.CLAUDE_ORG_ORDER || resolvePrefer();
153
- const logf = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-')), 'proxy.log');
369
+ const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-'));
370
+ const logf = path.join(runDir, 'proxy.log');
371
+ const stateFile = path.join(runDir, 'state.json');
154
372
  const logStream = fs.createWriteStream(logf);
155
- const { server, log, upstream } = createProxy({ order: order.split(','), log: logStream });
373
+ const { server, log, upstream, strategy } = createProxy({ order: order.split(','), log: logStream, stateFile });
156
374
 
157
375
  try {
158
376
  await new Promise((resolve, reject) => {
159
377
  server.once('error', reject);
160
378
  server.listen(0, '127.0.0.1', () => {
161
- log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host}`);
379
+ log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
162
380
  resolve();
163
381
  });
164
382
  });
@@ -167,7 +385,7 @@ async function proxyLaunch(args) {
167
385
  return 1;
168
386
  }
169
387
  const port = server.address().port;
170
- console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, log: ${logf})`);
388
+ console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, strategy: ${strategy}, log: ${logf})`);
171
389
 
172
390
  // A custom ANTHROPIC_BASE_URL + AUTH_TOKEN makes Claude Code treat the session
173
391
  // as API-key auth: prompt cache TTL drops to 5min and MCP tool search is
@@ -176,6 +394,7 @@ async function proxyLaunch(args) {
176
394
  ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
177
395
  ANTHROPIC_AUTH_TOKEN: 'claude-org-proxy', // dummy; the proxy replaces it
178
396
  CLAUDE_ORG_PROXY: '1', // marker: "connector MCPs unavailable" session
397
+ CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
179
398
  };
180
399
  if (!process.env.CLAUDE_CODE_PROMPT_CACHE_TTL) envExtra.CLAUDE_CODE_PROMPT_CACHE_TTL = '1h';
181
400
  if (!process.env.ENABLE_TOOL_SEARCH) envExtra.ENABLE_TOOL_SEARCH = 'true';
@@ -190,9 +409,9 @@ async function proxyLaunch(args) {
190
409
  // `node claude-org-proxy.js`).
191
410
  if (require.main === module) {
192
411
  const PORT = Number(process.env.PORT || 0);
193
- const { server, listOrgs, log, upstream } = createProxy();
412
+ const { server, listOrgs, log, upstream, strategy } = createProxy();
194
413
  server.listen(PORT, '127.0.0.1', () => {
195
- log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host}`);
414
+ log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
196
415
  });
197
416
  }
198
417
 
package/lib/status.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- const { spawn } = require('child_process');
2
+ const { spawnClaude } = require('./claude-spawn');
3
3
  const { listOrgs, readToken } = require('./config');
4
4
  const { probe } = require('./probe');
5
5
 
@@ -10,8 +10,7 @@ const KEYS_RE_DEFAULT = /"(loggedIn|authMethod|orgName|subscriptionType)"/;
10
10
  // Run `claude auth status` and return combined stdout+stderr (like 2>&1).
11
11
  function authStatus(envExtra = {}) {
12
12
  return new Promise(resolve => {
13
- const child = spawn('claude', ['auth', 'status'], {
14
- shell: process.platform === 'win32',
13
+ const child = spawnClaude(['auth', 'status'], {
15
14
  env: { ...process.env, ...envExtra },
16
15
  });
17
16
  const chunks = [];
@@ -22,7 +21,8 @@ function authStatus(envExtra = {}) {
22
21
  });
23
22
  }
24
23
 
25
- async function statusAll() {
24
+ // model: passed to probe (undefined -> CLAUDE_ORG_PROBE_MODEL or Haiku).
25
+ async function statusAll(model) {
26
26
  for (const name of listOrgs()) {
27
27
  const tok = readToken(name);
28
28
  if (tok === null) continue;
@@ -30,7 +30,7 @@ async function statusAll() {
30
30
  const out = await authStatus({ CLAUDE_CODE_OAUTH_TOKEN: tok });
31
31
  const lines = out.split('\n').filter(l => KEYS_RE.test(l));
32
32
  console.log(lines.length ? lines.join('\n') : ' (auth status failed)');
33
- const r = await probe(name);
33
+ const r = await probe(name, model);
34
34
  if (r.line) console.log(` ${r.line}`);
35
35
  }
36
36
  console.log('== default (no env) ==');
package/lib/usage.js CHANGED
@@ -24,11 +24,15 @@ token org alphabetically):
24
24
  claude-org auto [claude args...] launch on <prefer> if usable, else default
25
25
  (decided once at launch)
26
26
  claude-org <name> [claude args...] e.g. claude-org work --resume
27
- claude-org probe [<name>] print the org's 5h window state on one line
28
- (exit 0 when allowed)
27
+ claude-org probe [<name>] [--model <id>]
28
+ print the org's 5h window state on one line
29
+ (exit 0 when allowed); with --model, probe with
30
+ that model and add model_status= (exit 1 when
31
+ the org refused it)
29
32
  claude-org watch wait until <prefer> recovers, then notify
30
33
  (terminal + optional CLAUDE_ORG_NOTIFY_CMD)
31
- claude-org status show \`claude auth status\` for each org token
34
+ claude-org status [--model <id>] show \`claude auth status\` and probe for each
35
+ org token
32
36
  claude-org list list saved org names
33
37
 
34
38
  auto / <name> only read env at launch, so they cannot switch mid-session. To keep
package/lib/watch.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { spawn } = require('child_process');
3
+ const { q } = require('./claude-spawn');
3
4
  const { resolvePrefer } = require('./config');
4
5
  const { probe } = require('./probe');
5
6
  const { hhmmss } = require('./time');
@@ -16,7 +17,6 @@ function notify(title, message) {
16
17
  }
17
18
  // Quote args ourselves: spawn(shell:true) does not quote on Windows and the
18
19
  // title/message contain spaces.
19
- const q = s => `"${String(s).replace(/(["\\$`])/g, '\\$1')}"`;
20
20
  return new Promise(resolve => {
21
21
  const child = spawn(`${cmd} ${q(title)} ${q(message)}`, { shell: true, stdio: 'ignore' });
22
22
  child.on('error', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-org",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Switch Claude Code between multiple Anthropic organizations, with automatic failover on the 5-hour rate limit",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -19,7 +19,8 @@
19
19
  "CHANGELOG.md"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test"
22
+ "test": "node --test",
23
+ "release": "node scripts/release.js"
23
24
  },
24
25
  "keywords": [
25
26
  "claude-code",