claude-org 0.2.2 → 0.4.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 +18 -0
- package/LICENSE +21 -21
- package/README.md +71 -9
- package/bin/claude-org.js +21 -3
- package/lib/config.js +42 -4
- package/lib/launch.js +38 -3
- package/lib/pick.js +9 -4
- package/lib/probe.js +90 -29
- package/lib/proxy.js +419 -105
- package/lib/status.js +3 -2
- package/lib/usage.js +7 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.0 — 2026-10-11
|
|
6
|
+
|
|
7
|
+
- `proxy`: hardened for unattended use under a supervisor (#22)
|
|
8
|
+
- only the `claude` it launched can use it: a per-session secret is checked (constant time) on every request, `Host` must be `127.0.0.1:<port>`, only `GET` / `POST` / `DELETE` under `/v1/` are forwarded, and bodies over 64 MB get 413. Standalone `node lib/proxy.js` needs `CLAUDE_ORG_PROXY_SECRET`
|
|
9
|
+
- it no longer crashes or hangs: an unreadable token dir gives 503, every request handler error is caught, hop-by-hop headers are dropped, an upstream that sends nothing for 10 min is given up and the next org tried, and a `claude` that cancels or exits mid-response cancels the upstream request
|
|
10
|
+
- the launcher forwards `SIGTERM` / `SIGHUP` (and `SIGINT` without a terminal) to `claude`, waits for it, and sends `SIGKILL` after 10 s, so killing the launcher pid no longer orphans `claude`; with a terminal, Ctrl-C no longer kills the proxy under a running `claude`
|
|
11
|
+
- token files must be mode `0600` and owned by the user, org names are limited to `[A-Za-z0-9_-]`, and `CLAUDE_ORG_UPSTREAM` other than `https://api.anthropic.com` is refused unless `CLAUDE_ORG_UNSAFE_UPSTREAM=1`
|
|
12
|
+
- a 401 from an org (expired token) fails over to the next org, and "no org works" events are logged on fixed-format `ALERT ...` lines a supervisor can grep (README, "Unattended use")
|
|
13
|
+
- the proxy log never records a request's query string (a refused `/v1/messages?...` is logged as such), so a secret put there cannot leak into the log
|
|
14
|
+
- README: unattended runs must use proxy mode; how to keep the token dir away from `claude`'s shell and Read tool
|
|
15
|
+
|
|
16
|
+
## 0.3.0 — 2026-10-10
|
|
17
|
+
|
|
18
|
+
- `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)
|
|
19
|
+
- `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)
|
|
20
|
+
- `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)
|
|
21
|
+
- Docs: proxy mode now explains that Agent-tool subagents go through the same proxy and what that implies (#16)
|
|
22
|
+
|
|
5
23
|
## 0.2.2 — 2026-10-10
|
|
6
24
|
|
|
7
25
|
- 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)
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 miyashita337
|
|
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.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 miyashita337
|
|
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
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
|
|
@@ -73,17 +82,34 @@ claude-org proxy --resume
|
|
|
73
82
|
```
|
|
74
83
|
|
|
75
84
|
1. A session-scoped proxy starts on `127.0.0.1:<random port>` (one per session; no port collisions).
|
|
76
|
-
2. `claude` launches with `ANTHROPIC_BASE_URL` pointing at the proxy and a **
|
|
77
|
-
3. The proxy
|
|
78
|
-
4.
|
|
85
|
+
2. `claude` launches with `ANTHROPIC_BASE_URL` pointing at the proxy and a **per-session secret** as its bearer token (`ANTHROPIC_AUTH_TOKEN`, generated with `crypto.randomBytes`; it exists only in that `claude`'s environment).
|
|
86
|
+
3. The proxy serves only requests that carry that secret, name it in `Host` (`127.0.0.1:<port>`, so a web page cannot reach it by DNS rebinding) and are `GET` / `POST` / `DELETE` under `/v1/`; anything else is refused with 401 / 403 / 404 / 405 and never forwarded. Request bodies over 64 MB get 413.
|
|
87
|
+
4. The proxy replaces the `Authorization` header with a real org token, drops hop-by-hop headers, and forwards to `api.anthropic.com` (any other upstream is refused unless `CLAUDE_ORG_UNSAFE_UPSTREAM=1`). 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. An org whose token is rejected (401: expired or revoked) is skipped the same way for 10 minutes.
|
|
88
|
+
5. Claude Code only ever sees the successful response — the org switched mid-session. SSE streaming is piped through untouched. When Claude Code cancels or exits mid-response, the upstream request is cancelled too; an upstream that sends nothing for 10 minutes is given up and the next org tried.
|
|
79
89
|
|
|
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: ...)`).
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
**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.
|
|
93
|
+
|
|
94
|
+
**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.:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
[ -n "$CLAUDE_ORG_STATE_FILE" ] && [ -f "$CLAUDE_ORG_STATE_FILE" ] && node -p "require(process.env.CLAUDE_ORG_STATE_FILE).org"
|
|
98
|
+
```
|
|
81
99
|
|
|
82
100
|
**Limitations of proxy mode**
|
|
83
101
|
|
|
84
102
|
- 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
103
|
- Each proxy serves one session and dies when `claude` exits.
|
|
86
104
|
|
|
105
|
+
**Subagents**
|
|
106
|
+
|
|
107
|
+
- 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).
|
|
108
|
+
- 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`).
|
|
109
|
+
- The connector MCP limitation above applies to subagents too.
|
|
110
|
+
- 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.
|
|
111
|
+
- Work routed to a Team / Enterprise org (e.g. your company's) counts toward that org's usage, which its admins can see.
|
|
112
|
+
|
|
87
113
|
### Prompt cache & tool search
|
|
88
114
|
|
|
89
115
|
A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as API-key auth, which changes two defaults:
|
|
@@ -97,7 +123,38 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
97
123
|
|---|---|---|---|
|
|
98
124
|
| `claude` | no (default org) | yes | connector work |
|
|
99
125
|
| `claude-org auto --resume` | at launch only | no on preferred org / yes on default | short tasks |
|
|
100
|
-
| `claude-org proxy --resume` | **automatic** | no | long-running work, agents |
|
|
126
|
+
| `claude-org proxy --resume` | **automatic** | no | long-running work, agents, unattended runs |
|
|
127
|
+
|
|
128
|
+
### Unattended use (supervisors, `--dangerously-skip-permissions`)
|
|
129
|
+
|
|
130
|
+
When a supervisor starts `claude` without a person watching (e.g. `claude -p` with `--dangerously-skip-permissions`), launch it through `claude-org proxy`, not `claude-org <org>` / `auto`:
|
|
131
|
+
|
|
132
|
+
- **Use proxy mode only.** `claude-org <org>` and `auto` put the org token into `claude`'s environment as `CLAUDE_CODE_OAUTH_TOKEN`, where a prompt-injected `claude` can read it. In proxy mode the tokens stay in the launcher process; `claude` only holds the per-session proxy secret, which is worth nothing outside that session.
|
|
133
|
+
- **Signals.** The launcher forwards `SIGTERM` / `SIGHUP` (and `SIGINT` when it has no terminal) to `claude`, waits for it to exit, and reports its exit code (`128+signal` when killed); after 10 s it sends `SIGKILL`. So a supervisor that kills the launcher pid does not leave an orphaned `claude`, and the proxy lives exactly as long as `claude` does. With a terminal, Ctrl-C goes to `claude` only (the launcher stays until `claude` exits).
|
|
134
|
+
- **Keep the token files away from `claude`'s shell.** A `claude` running with permissions skipped can `cat ~/.claude/org-tokens/*.token` from Bash. The proxy refuses token files that are not mode `0600` and owned by you, but that does not stop your own `claude`. Deny the directory to sandboxed commands and to the Read tool in the settings of the project the unattended session runs in (both are needed: the sandbox covers shell commands only, the Read tool follows permission rules):
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"permissions": { "deny": ["Read(~/.claude/org-tokens/**)"] },
|
|
139
|
+
"sandbox": {
|
|
140
|
+
"enabled": true,
|
|
141
|
+
"credentials": { "files": [{ "path": "~/.claude/org-tokens", "mode": "deny" }] }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Source: Claude Code docs, [Configure the sandboxed Bash tool](https://code.claude.com/docs/en/sandboxing) (`sandbox.credentials.files` with `"mode": "deny"` applies the same read block as `sandbox.filesystem.denyRead`; the sandbox runs on macOS, Linux and WSL2, not native Windows). Running the proxy as a separate daemon under another user would close the gap fully; it is not implemented, and would build on the shared proxy discussed in #14 / #18.
|
|
147
|
+
- **Detecting "no org works" from outside.** The proxy log (path on the `-> proxy` launch line) has one fixed-format line per such event, so a supervisor or a notifier can grep for `ALERT `:
|
|
148
|
+
|
|
149
|
+
| Line | Meaning |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `ALERT token-rejected org=<org> status=401 -> run: claude setup-token ...` | that org's token is expired or revoked; the org is skipped for 10 min |
|
|
152
|
+
| `ALERT no-org-available model=<model> reason=rate-limited status=429` | every org is over its window; the 429 went to `claude` |
|
|
153
|
+
| `ALERT no-org-available model=<model> reason=model-rejected status=429` | every org refuses this model; switch the model |
|
|
154
|
+
| `ALERT no-org-available model=<model> reason=unreachable status=503` | no org answered (network, timeouts, bad token files) |
|
|
155
|
+
| `ALERT no-org-available model=<model> reason=no-tokens status=503` | the token dir is missing or has no usable `.token` file |
|
|
156
|
+
|
|
157
|
+
The state file (`CLAUDE_ORG_STATE_FILE`) records the same outcome as `status` (`503` with `"org": null`, or `401` with the org).
|
|
101
158
|
|
|
102
159
|
## Environment variables
|
|
103
160
|
|
|
@@ -105,17 +162,22 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
105
162
|
|---|---|---|
|
|
106
163
|
| `CLAUDE_ORG_PREFER` | (unset) | Preferred org for `auto` / `watch` / `proxy` |
|
|
107
164
|
| `CLAUDE_ORG_ORDER` | (unset) | Comma-separated org priority for `proxy`; unlisted orgs follow alphabetically |
|
|
165
|
+
| `CLAUDE_ORG_STRATEGY` | (unset: priority order) | `least-utilized`: `proxy` sends each request to the usable org with the lowest 5h utilization (see proxy mode) |
|
|
166
|
+
| `CLAUDE_ORG_PROBE_MODEL` | (unset: Haiku) | Model used by `probe` / `status` / `auto` to check the org; adds `model_status=` to the probe line |
|
|
108
167
|
| `CLAUDE_ORG_TOKEN_DIR` | `~/.claude/org-tokens` | Where `<name>.token` files live |
|
|
109
168
|
| `CLAUDE_ORG_NOTIFY_CMD` | (unset) | `watch` notification command, run as `<cmd> "<title>" "<message>"` via the OS shell |
|
|
110
169
|
| `CLAUDE_ORG_WATCH_MAX_SLEEP` | `18000` | Max seconds between `watch` re-checks (values < 60 also lower the 60s minimum — for tests) |
|
|
111
|
-
| `CLAUDE_ORG_UPSTREAM` | `https://api.anthropic.com` | API base URL for `probe` and `proxy` (stub upstream in tests) |
|
|
170
|
+
| `CLAUDE_ORG_UPSTREAM` | `https://api.anthropic.com` | API base URL for `probe` and `proxy` (stub upstream in tests). `proxy` refuses any other value unless `CLAUDE_ORG_UNSAFE_UPSTREAM=1`, since org tokens are sent there |
|
|
171
|
+
| `CLAUDE_ORG_UNSAFE_UPSTREAM` | (unset) | `1` lets `proxy` forward to a `CLAUDE_ORG_UPSTREAM` other than `api.anthropic.com` (tests only) |
|
|
172
|
+
| `CLAUDE_ORG_PROXY_SECRET` | (unset) | Standalone `node lib/proxy.js` only: the bearer value clients must send. `claude-org proxy` generates one per session itself |
|
|
112
173
|
| `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect connector-less sessions |
|
|
174
|
+
| `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
175
|
| `CLAUDE_ORG_FORCE_PROBE` | (unset) | Test seam: `allowed` / `rejected[:<epoch>]` / `rejected_until:<epoch>` fakes `probe` results without API calls |
|
|
114
176
|
| `CLAUDE_ORG_REJECT_FILE` | (unset) | Test seam: orgs listed comma-separated in this file are treated as rate-limited by `proxy` |
|
|
115
177
|
|
|
116
178
|
## Notes
|
|
117
179
|
|
|
118
|
-
- **Token safety**: tokens are read from files only and never printed to logs, errors, or test snapshots. The proxy binds to `127.0.0.1` only.
|
|
180
|
+
- **Token safety**: tokens are read from files only and never printed to logs, errors, or test snapshots. A token file must be mode `0600` and owned by you (`chmod 600 ~/.claude/org-tokens/*.token`); one that other users can read is refused. Org names are limited to letters, digits, `_` and `-`. The proxy binds to `127.0.0.1` only and serves only the `claude` it launched (see [Unattended use](#unattended-use-supervisors---dangerously-skip-permissions)).
|
|
119
181
|
- **probe cost**: each `probe` sends one `max_tokens=1` request (~11 tokens billed to that org), once per `auto` launch.
|
|
120
182
|
- **Token expiry**: tokens last ~1 year. If `probe` returns `status=unknown` or `status` shows `(auth status failed)`, rerun `claude setup-token` and overwrite the file.
|
|
121
183
|
|
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
|
|
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.
|
|
48
|
+
return r.usable ? 0 : 1;
|
|
31
49
|
}
|
|
32
50
|
case 'pick':
|
|
33
51
|
console.log(await pick());
|
package/lib/config.js
CHANGED
|
@@ -7,7 +7,16 @@ function tokenDir() {
|
|
|
7
7
|
return process.env.CLAUDE_ORG_TOKEN_DIR || path.join(os.homedir(), '.claude', 'org-tokens');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
//
|
|
10
|
+
// An org name is used as a file name under the token dir, so anything that
|
|
11
|
+
// could leave the dir ("../x", "a/b") or confuse a shell is refused.
|
|
12
|
+
const ORG_NAME_RE = /^[\w-]+$/;
|
|
13
|
+
function validOrgName(name) {
|
|
14
|
+
return typeof name === 'string' && ORG_NAME_RE.test(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Saved org names: *.token files, extension stripped, sorted. A file whose
|
|
18
|
+
// name is not a valid org name is left out, and said so once.
|
|
19
|
+
const reportedBadNames = new Set();
|
|
11
20
|
function listOrgs(dir = tokenDir()) {
|
|
12
21
|
let files;
|
|
13
22
|
try {
|
|
@@ -15,12 +24,36 @@ function listOrgs(dir = tokenDir()) {
|
|
|
15
24
|
} catch {
|
|
16
25
|
return [];
|
|
17
26
|
}
|
|
18
|
-
|
|
27
|
+
const names = files.filter(f => f.endsWith('.token')).map(f => f.slice(0, -6));
|
|
28
|
+
for (const n of names) {
|
|
29
|
+
if (validOrgName(n) || reportedBadNames.has(n)) continue;
|
|
30
|
+
reportedBadNames.add(n);
|
|
31
|
+
console.error(`claude-org: ignoring ${path.join(dir, `${n}.token`)}: not an org name (letters, digits, _ and - only)`);
|
|
32
|
+
}
|
|
33
|
+
return names.filter(validOrgName).sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Why a token file must be private to the user: the proxy sends its content
|
|
37
|
+
// as a Bearer token, so a file another local user can read hands them that
|
|
38
|
+
// org. Returns '' when the file is fine, else the reason it is refused.
|
|
39
|
+
// Not checked on Windows (no POSIX mode bits there).
|
|
40
|
+
function tokenFileProblem(file) {
|
|
41
|
+
if (process.platform === 'win32' || typeof process.getuid !== 'function') return '';
|
|
42
|
+
const st = fs.statSync(file);
|
|
43
|
+
const me = process.getuid();
|
|
44
|
+
if (st.uid !== me) return `is owned by uid ${st.uid}, not by you (uid ${me})`;
|
|
45
|
+
if (st.mode & 0o077) return `is readable by other users (mode ${(st.mode & 0o777).toString(8)}); run: chmod 600 ${file}`;
|
|
46
|
+
return '';
|
|
19
47
|
}
|
|
20
48
|
|
|
21
49
|
// Read a token file, stripping CR/LF. Returns null (after printing guidance)
|
|
22
|
-
// when the file is missing or empty
|
|
50
|
+
// when the name is invalid, the file is missing or empty, or the file is not
|
|
51
|
+
// private to the user.
|
|
23
52
|
function readToken(name, dir = tokenDir()) {
|
|
53
|
+
if (!validOrgName(name)) {
|
|
54
|
+
console.error(`claude-org: invalid org name "${name}" (letters, digits, _ and - only)`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
24
57
|
const f = path.join(dir, `${name}.token`);
|
|
25
58
|
let raw = '';
|
|
26
59
|
try {
|
|
@@ -33,6 +66,11 @@ function readToken(name, dir = tokenDir()) {
|
|
|
33
66
|
console.error(` 取得: claude setup-token でブラウザから org '${name}' を選び、出力トークンを ${f} に保存(改行なし)`);
|
|
34
67
|
return null;
|
|
35
68
|
}
|
|
69
|
+
const problem = tokenFileProblem(f);
|
|
70
|
+
if (problem) {
|
|
71
|
+
console.error(`claude-org: token file ${f} ${problem}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
36
74
|
return raw.replace(/[\r\n]/g, '');
|
|
37
75
|
}
|
|
38
76
|
|
|
@@ -52,4 +90,4 @@ function resolvePrefer() {
|
|
|
52
90
|
process.exit(1);
|
|
53
91
|
}
|
|
54
92
|
|
|
55
|
-
module.exports = { tokenDir, listOrgs, readToken, resolvePrefer };
|
|
93
|
+
module.exports = { tokenDir, listOrgs, readToken, resolvePrefer, validOrgName, tokenFileProblem };
|
package/lib/launch.js
CHANGED
|
@@ -2,27 +2,62 @@
|
|
|
2
2
|
const { spawnClaude } = require('./claude-spawn');
|
|
3
3
|
const { readToken } = require('./config');
|
|
4
4
|
|
|
5
|
+
// After a forwarded SIGTERM / SIGHUP, how long claude gets to exit on its own
|
|
6
|
+
// before it is killed. Keeps a hung claude from outliving a supervisor's kill.
|
|
7
|
+
const KILL_GRACE_MS = 10 * 1000;
|
|
8
|
+
const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP', 'SIGINT'];
|
|
9
|
+
|
|
5
10
|
// Spawn claude with inherited stdio and extra env. Resolves to the child's
|
|
6
11
|
// exit code (128+n when killed by signal n, like a shell would report).
|
|
12
|
+
//
|
|
13
|
+
// Signals sent to this launcher (a supervisor's reaper or timeout kills the
|
|
14
|
+
// launcher pid, not claude) are forwarded to claude, and the launcher waits
|
|
15
|
+
// for claude to exit instead of dying first and leaving claude orphaned with
|
|
16
|
+
// its proxy gone. SIGINT from a terminal (Ctrl-C) reaches both processes at
|
|
17
|
+
// once; forwarding it would give claude a second Ctrl-C, which it reads as
|
|
18
|
+
// "exit". So with a terminal on any of stdin/stdout/stderr, SIGINT is left to
|
|
19
|
+
// claude; with none (headless, under a supervisor) it is forwarded like the
|
|
20
|
+
// others.
|
|
7
21
|
function runClaude(args, envExtra = {}) {
|
|
8
22
|
return new Promise(resolve => {
|
|
9
23
|
const child = spawnClaude(args, {
|
|
10
24
|
stdio: 'inherit',
|
|
11
25
|
env: { ...process.env, ...envExtra },
|
|
12
26
|
});
|
|
27
|
+
let killTimer = null;
|
|
28
|
+
const handlers = new Map();
|
|
29
|
+
for (const sig of FORWARDED_SIGNALS) {
|
|
30
|
+
const h = () => {
|
|
31
|
+
if (sig === 'SIGINT' && (process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY)) return;
|
|
32
|
+
try { child.kill(sig); } catch { /* already exited */ }
|
|
33
|
+
if (!killTimer) {
|
|
34
|
+
killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already exited */ } }, KILL_GRACE_MS);
|
|
35
|
+
killTimer.unref();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
handlers.set(sig, h);
|
|
39
|
+
process.on(sig, h);
|
|
40
|
+
}
|
|
41
|
+
const done = code => {
|
|
42
|
+
for (const [sig, h] of handlers) process.off(sig, h);
|
|
43
|
+
if (killTimer) clearTimeout(killTimer);
|
|
44
|
+
resolve(code);
|
|
45
|
+
};
|
|
13
46
|
child.on('error', e => {
|
|
14
47
|
console.error(`claude-org: failed to launch claude: ${e.message}`);
|
|
15
|
-
|
|
48
|
+
done(1);
|
|
16
49
|
});
|
|
17
50
|
child.on('exit', (code, signal) => {
|
|
18
|
-
if (code !== null) return
|
|
19
|
-
|
|
51
|
+
if (code !== null) return done(code);
|
|
52
|
+
done(signal ? 128 + ({ SIGINT: 2, SIGTERM: 15, SIGKILL: 9, SIGHUP: 1 }[signal] || 1) : 1);
|
|
20
53
|
});
|
|
21
54
|
});
|
|
22
55
|
}
|
|
23
56
|
|
|
24
57
|
// launch(name, args): 'default' launches a plain `claude`; a token org launches
|
|
25
58
|
// with CLAUDE_CODE_OAUTH_TOKEN set. Returns the process exit code.
|
|
59
|
+
// The token goes into claude's environment, where a prompt-injected claude can
|
|
60
|
+
// read it; unattended runs should use `claude-org proxy` instead (README).
|
|
26
61
|
async function launch(name, args) {
|
|
27
62
|
if (name === 'default') return runClaude(args);
|
|
28
63
|
const tok = readToken(name);
|
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)
|
|
7
|
-
// reason goes to stderr as a
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
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)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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 };
|