@anyslate/cli 0.3.0 → 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/README.md +125 -32
- package/package.json +2 -2
- package/src/auth.mjs +189 -32
- package/src/backoff.mjs +178 -0
- package/src/commands/checkpoint.mjs +13 -3
- package/src/commands/doctor.mjs +19 -0
- package/src/commands/hook.mjs +71 -3
- package/src/commands/login.mjs +36 -4
- package/src/commands/logout.mjs +4 -0
- package/src/commands/upload-artifact.mjs +12 -4
- package/src/credentials.mjs +32 -5
- package/src/guard.mjs +381 -0
- package/src/index.mjs +12 -0
- package/src/mcp-client.mjs +16 -5
package/README.md
CHANGED
|
@@ -1,39 +1,92 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://mcp.anyslate.io/logo-full-dark.png">
|
|
4
|
+
<img src="https://mcp.anyslate.io/logo-full-light.png" alt="AnySlate" width="280">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<h1 align="center">@anyslate/cli</h1>
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
<p align="center">
|
|
11
|
+
<strong>Your AI tools remember what you did. Automatically.</strong><br>
|
|
12
|
+
Lifecycle hooks, git commits and CI runs → your AnySlate memory, without you lifting a finger.
|
|
13
|
+
</p>
|
|
6
14
|
|
|
7
|
-
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://www.npmjs.com/package/@anyslate/cli"><img alt="npm" src="https://img.shields.io/npm/v/@anyslate/cli?color=f45f5f&labelColor=252c3e"></a>
|
|
17
|
+
<a href="https://nodejs.org"><img alt="node" src="https://img.shields.io/node/v/@anyslate/cli?color=f45f5f&labelColor=252c3e"></a>
|
|
18
|
+
<img alt="dependencies" src="https://img.shields.io/badge/runtime%20deps-0-f45f5f?labelColor=252c3e">
|
|
19
|
+
<img alt="license" src="https://img.shields.io/npm/l/@anyslate/cli?color=f45f5f&labelColor=252c3e">
|
|
20
|
+
</p>
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
---
|
|
10
23
|
|
|
11
|
-
|
|
24
|
+
## What is this?
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
**AnySlate** gives your AI tools a long-term memory — a place where decisions, tasks and
|
|
27
|
+
work history live, so Claude, Cursor or Codex can pick up where you left off instead of
|
|
28
|
+
starting cold every session.
|
|
14
29
|
|
|
15
|
-
|
|
30
|
+
**This CLI is how that memory gets filled in when no AI is watching.**
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
An AI can only record something if it decides to call a tool. But a lot of your real work
|
|
33
|
+
happens outside that: you commit code, CI deploys, you run the test suite, you make a call
|
|
34
|
+
while staring at a terminal. None of it has an AI present to write it down.
|
|
35
|
+
|
|
36
|
+
This CLI closes that gap. Your tools call it for you:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Claude Code fires a hook ─┐
|
|
40
|
+
git runs post-commit ─┼─→ anyslate ─→ AnySlate memory
|
|
41
|
+
CI finishes a deploy ─┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Think of it like a fitness tracker for your engineering work. You don't tell a fitness
|
|
45
|
+
tracker "I just took 47 steps." You wear it, you walk, the number goes up. Same idea:
|
|
46
|
+
install this once, paste one config block, and capture happens in the background forever.
|
|
47
|
+
|
|
48
|
+
> **You will almost never type `anyslate` yourself.** It is plumbing that other tools call.
|
|
49
|
+
> Setup takes about five minutes and ends with `anyslate doctor` confirming it works.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm i -g @anyslate/cli # 1. install
|
|
55
|
+
anyslate login # 2. sign in (opens your browser)
|
|
56
|
+
anyslate doctor # 3. confirm it works
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then paste the hook block into `~/.claude/settings.json` — see
|
|
60
|
+
[Wiring into Claude Code](#wiring-into-claude-code) — and restart Claude Code. Done.
|
|
61
|
+
|
|
62
|
+
## What actually gets captured
|
|
63
|
+
|
|
64
|
+
| Trigger | Who calls the CLI | What lands in your memory |
|
|
18
65
|
|---|---|---|
|
|
19
|
-
| You open a Claude Code session |
|
|
20
|
-
| Claude edits `routes/auth.ts` |
|
|
21
|
-
| Claude runs `npm test` |
|
|
22
|
-
| You `git commit` |
|
|
23
|
-
|
|
|
24
|
-
| You close the laptop |
|
|
25
|
-
| **You
|
|
26
|
-
| **You want to
|
|
66
|
+
| You open a Claude Code session | `SessionStart` hook | Session start + working directory; the AnySlate memory is created on first sight |
|
|
67
|
+
| Claude edits `routes/auth.ts` | `PostToolUse` hook | `Edited routes/auth.ts` in the activity ledger |
|
|
68
|
+
| Claude runs `npm test` | `PostToolUse` hook | Command + exit status in the activity ledger |
|
|
69
|
+
| You `git commit` | git `post-commit` hook | Commit metadata and diff stats |
|
|
70
|
+
| A teammate merges your PR | GitHub webhook (no CLI needed) | PR-merged event |
|
|
71
|
+
| You close the laptop | `Stop` hook | Session-end marker |
|
|
72
|
+
| **You want to record a decision** | **you typing `anyslate checkpoint`** | **Captured immediately, as a decision** |
|
|
73
|
+
| **You want to attach a file** | **you typing `anyslate upload-artifact`** | **Stored, returns `cloud://artifact/<id>`** |
|
|
27
74
|
|
|
28
|
-
The first six rows are automatic
|
|
75
|
+
The first six rows are automatic. The last two are the rare moments you'd type something.
|
|
29
76
|
|
|
30
|
-
### What hook captures
|
|
77
|
+
### What hook captures write — read this before forming expectations
|
|
31
78
|
|
|
32
|
-
Hook captures land in a dedicated **`## Activity Ledger`** section of your memory page:
|
|
79
|
+
Hook captures land in a dedicated **`## Activity Ledger`** section of your memory page:
|
|
80
|
+
which files were touched, which commands ran, what exit status they returned. That is the
|
|
81
|
+
whole contract. The ledger is capped (25 files / 15 commands per entry) and is built
|
|
82
|
+
deterministically — **no LLM runs in the hook path**, so background capture never bills
|
|
83
|
+
against your AI quota.
|
|
33
84
|
|
|
34
|
-
Hook captures **do not** write to
|
|
85
|
+
Hook captures **do not** write to **Key Decisions** or **Open Tasks**. Those stay human-
|
|
86
|
+
and LLM-authored. A hook firing on `Bash` has no decision inside it to extract, and the
|
|
87
|
+
product no longer pretends otherwise.
|
|
35
88
|
|
|
36
|
-
If you want a decision recorded,
|
|
89
|
+
If you want a decision recorded, say so:
|
|
37
90
|
|
|
38
91
|
```bash
|
|
39
92
|
anyslate checkpoint --note "Decided: Redis for session cache, not memcached"
|
|
@@ -41,19 +94,26 @@ anyslate checkpoint --note "Decided: Redis for session cache, not memcached"
|
|
|
41
94
|
|
|
42
95
|
### Approval: hook captures auto-promote
|
|
43
96
|
|
|
44
|
-
Every lifecycle hook
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
File edits used to map to `artifact_produced`, which is high-risk by classification. That was wrong on two counts: it forced a manual approval on the single most common event in a session, and `artifact_produced` is a contract meaning "I stored an artifact, here is its id" - which a hook can never satisfy, so the server rejected it. Edits now map to `task_completed` and carry the touched paths in the ledger.
|
|
97
|
+
Every lifecycle hook — `Edit` / `Write` / `MultiEdit`, `Bash`, and the session start/stop
|
|
98
|
+
markers — classifies **low-risk** and auto-promotes on its own, 30–90 seconds after
|
|
99
|
+
capture. An edit-heavy session does **not** build an approval queue.
|
|
49
100
|
|
|
50
|
-
|
|
101
|
+
Rows still wait for approval in **AI Memory → Activity** when they are genuinely riskier: a
|
|
102
|
+
capture aimed at a different session than the one that produced it, a decision that
|
|
103
|
+
supersedes an earlier one, a submission from an untrusted source, or one carrying
|
|
104
|
+
`confidence < 0.8`.
|
|
51
105
|
|
|
52
|
-
|
|
106
|
+
## Why bother
|
|
53
107
|
|
|
54
108
|
- **You stop forgetting to checkpoint.** Capture happens whether you remember or not.
|
|
55
|
-
- **
|
|
56
|
-
|
|
109
|
+
- **You see what actually happened, not just what the AI claimed.** If the agent says it
|
|
110
|
+
implemented `TokenRefreshService` and the diff is whitespace, that discrepancy is
|
|
111
|
+
visible. You trust the AI because you can check, not because you have to assume.
|
|
112
|
+
- **Your work follows you across tools.** Claude Code at home, Cursor at work, a terminal
|
|
113
|
+
in between — all feeding one memory. (Cursor and Windsurf feed it through the in-host
|
|
114
|
+
MCP server rather than this CLI — see the [FAQ](#faq).)
|
|
115
|
+
- **Nothing is canonical until it is.** Anything pending can be rejected without ever
|
|
116
|
+
touching a memory page.
|
|
57
117
|
|
|
58
118
|
## Install
|
|
59
119
|
|
|
@@ -136,6 +196,8 @@ export ANYSLATE_HANDLE=mh_xxxxxxxx # optional, scope to one capab
|
|
|
136
196
|
export ANYSLATE_DISABLE=1 # optional, disables capture for this shell
|
|
137
197
|
export ANYSLATE_HOME=/path/to/dir # optional, overrides ~/.anyslate
|
|
138
198
|
export ANYSLATE_STDIN_TIMEOUT_MS=10000 # optional, stdin idle timeout (0 disables)
|
|
199
|
+
export ANYSLATE_HOOK_TIMEOUT_MS=5000 # optional, request budget for one hook
|
|
200
|
+
export ANYSLATE_MAX_CALLS_PER_MINUTE=60 # optional, local call ceiling (0 disables)
|
|
139
201
|
```
|
|
140
202
|
|
|
141
203
|
Env vars override `~/.anyslate/cli.json`.
|
|
@@ -154,6 +216,24 @@ Capability handles are `mh_` + a 32-character id (e.g. `mh_V1StGXR8Z5jdHi6BmyT0a
|
|
|
154
216
|
|
|
155
217
|
**If your token was minted with a memory scope, it already carries that scope server-side and `ANYSLATE_HANDLE` / `--handle` is ignored.** Scoped tokens cannot be widened per-call by design. To capture workspace-wide, mint an unscoped token. `anyslate doctor` reports which case you're in.
|
|
156
218
|
|
|
219
|
+
### When capture pauses itself
|
|
220
|
+
|
|
221
|
+
Since 0.4.0 the CLI refuses to keep calling a server that is refusing it. Three things changed:
|
|
222
|
+
|
|
223
|
+
- **No unbounded retries.** `checkpoint` and `upload-artifact` retry at most 3 times, with exponential backoff and jitter and a 15-second total sleep budget. `hook` does not retry at all: it fires on every tool call, so a retry there is a doubled request rate in exchange for one row in an activity feed.
|
|
224
|
+
- **`Retry-After` is obeyed.** A `429` or `503` that names a wait pauses this machine for exactly that long. The CLI never sleeps through a long one - the process exits and the pause is honoured by whichever process runs next.
|
|
225
|
+
- **A circuit breaker that outlives the process.** Every hook is a separate process, so an in-memory breaker would be worthless. After 3 consecutive `401`s - or one definitively dead credential, such as a refresh token the server has already replaced - capture pauses, the pause is recorded in `~/.anyslate/cli-guard.json`, and the CLI tells you once:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
anyslate: capture paused until 2026-08-02T12:15:00.000Z — repeated authentication failures (invalid_grant). Run `anyslate login` to sign in again; no requests are sent until then.
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
After that one line, paused hooks are completely silent and make **no** network request. Repeated pauses escalate: 15 minutes, then 1 hour, 6 hours, 24 hours.
|
|
232
|
+
|
|
233
|
+
The pause is tied to the `apiUrl` + credential that earned it. `anyslate login` clears it immediately, and so does pointing `--api-url` somewhere else or swapping the token - fixing your setup is never punished by a wait. `anyslate doctor` always reports an active pause, and is itself never blocked by one.
|
|
234
|
+
|
|
235
|
+
`ANYSLATE_MAX_CALLS_PER_MINUTE` is the last line of defence: a machine-wide ceiling (default 60/minute) on capture calls, so a runaway agent loop cannot become a load test. It is far above any real session; set it to `0` to disable.
|
|
236
|
+
|
|
157
237
|
### The two URL conventions - the one thing people get wrong
|
|
158
238
|
|
|
159
239
|
AnySlate ships two clients and they want **different** URLs. This trips up nearly everyone:
|
|
@@ -237,7 +317,20 @@ Content is read as UTF-8. Binary files are not supported and are refused rather
|
|
|
237
317
|
|
|
238
318
|
Two paths, one config file. `--token` selects the static path; its absence selects the browser flow.
|
|
239
319
|
|
|
240
|
-
Both end the same way: one live request to `/mcp/auth/verify` that proves reachability, URL shape, token validity and scopes in a single round trip, then a `0600` write to `~/.anyslate/cli.json`. **On verification failure it writes nothing and exits non-zero.** Both
|
|
320
|
+
Both end the same way: one live request to `/mcp/auth/verify` that proves reachability, URL shape, token validity and scopes in a single round trip, then a `0600` write to `~/.anyslate/cli.json`. **On verification failure it writes nothing and exits non-zero.** Both preserve the `handle` you already had.
|
|
321
|
+
|
|
322
|
+
**The sign-in target is explicit, never sticky.** Omitting `--api-url` always means
|
|
323
|
+
**production**, even if your config currently points somewhere else — the stored value is
|
|
324
|
+
deliberately not inherited. If it does point elsewhere, the CLI says so and shows you the
|
|
325
|
+
flag to use:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
anyslate: signing in to production (https://mcp.anyslate.io).
|
|
329
|
+
anyslate: your config points at https://…workers.dev — pass `--api-url https://…workers.dev` to sign in there instead.
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
This matters because the target ends up in a URL printed to your terminal and opened in a
|
|
333
|
+
browser. A short command should never quietly aim at a non-production host.
|
|
241
334
|
|
|
242
335
|
A token missing the `memory:write` scope is a **warning**, not a block - but `anyslate hook` needs it, so heed it.
|
|
243
336
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyslate/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AnySlate CLI - lifecycle hooks, git/CI capture, and manual checkpoints for AI memory. Validates its connection at login, diagnoses itself with `anyslate doctor`, and fails open without failing silent.",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "AnySlate CLI - lifecycle hooks, git/CI capture, and manual checkpoints for AI memory. Validates its connection at login, diagnoses itself with `anyslate doctor`, backs off and circuit-breaks rather than retrying, and fails open without failing silent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"anyslate": "./bin/anyslate.mjs"
|
package/src/auth.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// The rules that fall out of that:
|
|
10
10
|
//
|
|
11
11
|
// * Refresh PROACTIVELY, 5 minutes ahead of expiry, before the call. Waiting
|
|
12
|
-
// for the 401 spends a round trip and, on the hook path, risks the
|
|
12
|
+
// for the 401 spends a round trip and, on the hook path, risks the timeout
|
|
13
13
|
// budget.
|
|
14
14
|
// * On a 401, refresh EXACTLY ONCE and retry EXACTLY ONCE. A loop here is a
|
|
15
15
|
// self-inflicted rate limit against an endpoint that is already refusing us.
|
|
@@ -19,10 +19,37 @@
|
|
|
19
19
|
// a process that must not hang.
|
|
20
20
|
// * Never throw. Callers are fail-open paths; a rejected promise here would
|
|
21
21
|
// become an uncaught exception in a hook and a non-zero exit.
|
|
22
|
+
//
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 2026-08-02 — this file is where the client-side storm was possible
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// "Refresh once, retry once" was already true PER PROCESS, and it was not
|
|
27
|
+
// enough: every hook is a fresh process, so one machine with a stale refresh
|
|
28
|
+
// token re-derived the same doomed `POST /oauth/token` + `POST /mcp` pair fifty
|
|
29
|
+
// times a minute for hours, and no single invocation was misbehaving.
|
|
30
|
+
//
|
|
31
|
+
// Three additions close that, all funnelled through `callToolWithAuth` because
|
|
32
|
+
// it is the one choke point every capture command already goes through:
|
|
33
|
+
//
|
|
34
|
+
// 1. a PERSISTED circuit breaker (guard.mjs) consulted BEFORE any credential
|
|
35
|
+
// is resolved — an open breaker costs the server exactly zero requests,
|
|
36
|
+
// 2. bounded retry with jitter and a hard attempt cap (backoff.mjs), with
|
|
37
|
+
// `Retry-After` honoured rather than ignored,
|
|
38
|
+
// 3. the refresh stays paired with the ONE call that needed it, even across
|
|
39
|
+
// retries — a retried call never re-mints.
|
|
22
40
|
|
|
23
41
|
import { readConfigFile, updateConfigFile, withRefreshLock } from './credentials.mjs';
|
|
24
|
-
import { callTool } from './mcp-client.mjs';
|
|
42
|
+
import { callTool, formatCallFailure } from './mcp-client.mjs';
|
|
25
43
|
import { discover, refreshAccessToken, REFRESH_SKEW_MS } from './oauth.mjs';
|
|
44
|
+
import { DEFAULT_RETRY, classifyFailure, planRetry, retryAfterMsOf, sleep } from './backoff.mjs';
|
|
45
|
+
import {
|
|
46
|
+
breakerGate,
|
|
47
|
+
breakerNotice,
|
|
48
|
+
ceilingNotice,
|
|
49
|
+
recordCallFailure,
|
|
50
|
+
recordCallSuccess,
|
|
51
|
+
reserveCallSlot,
|
|
52
|
+
} from './guard.mjs';
|
|
26
53
|
|
|
27
54
|
export { REFRESH_SKEW_MS };
|
|
28
55
|
|
|
@@ -105,12 +132,17 @@ async function refreshEndpoints({ oauth, root, fetchImpl }) {
|
|
|
105
132
|
* "somebody else already refreshed" short-circuit hands the caller back the
|
|
106
133
|
* very token that just 401'd, and the retry fails identically.
|
|
107
134
|
*
|
|
135
|
+
* `lockWaitMs` exists for the hook path. The default 10s wait is fine for a
|
|
136
|
+
* user-initiated command but is 10s of a lifecycle hook standing in front of
|
|
137
|
+
* the user's editor, and a machine whose refreshes are all failing is exactly
|
|
138
|
+
* the machine where every hook queues behind the last one.
|
|
139
|
+
*
|
|
108
140
|
* @param {{env: NodeJS.ProcessEnv, root: string, fetchImpl?: typeof fetch, now?: number,
|
|
109
|
-
* staleToken?: string|null}} opts
|
|
141
|
+
* staleToken?: string|null, lockWaitMs?: number}} opts
|
|
110
142
|
* @returns {Promise<{ok: true, token: string, rotated: boolean, oauth: object}
|
|
111
143
|
* | {ok: false, code: string, message: string}>}
|
|
112
144
|
*/
|
|
113
|
-
export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null }) {
|
|
145
|
+
export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null, lockWaitMs }) {
|
|
114
146
|
return withRefreshLock(
|
|
115
147
|
async () => {
|
|
116
148
|
// Re-read INSIDE the lock. While we queued, a sibling hook may have done
|
|
@@ -180,7 +212,7 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
|
|
|
180
212
|
}
|
|
181
213
|
return { ok: true, token: next.access_token, rotated: true, oauth: next };
|
|
182
214
|
},
|
|
183
|
-
{ env },
|
|
215
|
+
{ env, waitMs: lockWaitMs },
|
|
184
216
|
);
|
|
185
217
|
}
|
|
186
218
|
|
|
@@ -194,11 +226,12 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
|
|
|
194
226
|
* static token also exists, we fall back to it rather than failing the call —
|
|
195
227
|
* with `warning` set, so the caller can say so.
|
|
196
228
|
*
|
|
197
|
-
* @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean,
|
|
229
|
+
* @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean,
|
|
230
|
+
* now?: number, lockWaitMs?: number}} opts
|
|
198
231
|
* @returns {Promise<{ok: true, token: string, mode: 'env'|'oauth'|'static', refreshed: boolean, warning?: string}
|
|
199
232
|
* | {ok: false, code: string, message: string, mode: string}>}
|
|
200
233
|
*/
|
|
201
|
-
export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now }) {
|
|
234
|
+
export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now, lockWaitMs }) {
|
|
202
235
|
if (cfg?.sources?.mcpToken === 'env' && cfg.mcpToken) {
|
|
203
236
|
return { ok: true, token: cfg.mcpToken, mode: 'env', refreshed: false };
|
|
204
237
|
}
|
|
@@ -216,6 +249,7 @@ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false
|
|
|
216
249
|
fetchImpl,
|
|
217
250
|
now,
|
|
218
251
|
staleToken: force ? (oauth.access_token ?? null) : null,
|
|
252
|
+
lockWaitMs,
|
|
219
253
|
});
|
|
220
254
|
if (refreshed.ok) {
|
|
221
255
|
return { ok: true, token: refreshed.token, mode: 'oauth', refreshed: refreshed.rotated };
|
|
@@ -246,44 +280,137 @@ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false
|
|
|
246
280
|
}
|
|
247
281
|
|
|
248
282
|
/**
|
|
249
|
-
* `callTool` with the
|
|
250
|
-
*
|
|
283
|
+
* `callTool` with the whole availability contract wrapped around it: the
|
|
284
|
+
* persisted breaker, the machine ceiling, proactive refresh, at most one
|
|
285
|
+
* refresh-and-retry on a 401, and bounded backoff on anything transient.
|
|
286
|
+
*
|
|
287
|
+
* The 401 retry is gated on `mode === 'oauth'`: a static token that 401s is
|
|
288
|
+
* revoked or mistyped, and re-sending it cannot help.
|
|
251
289
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
290
|
+
* ATTEMPTS ARE PROVABLY BOUNDED. The loop condition is a counter compared to
|
|
291
|
+
* `policy.maxAttempts + 1` — the `+1` is the single post-refresh retry, which
|
|
292
|
+
* must be allowed even on the hook path (`maxAttempts: 1`) because an access
|
|
293
|
+
* token that aged out mid-session is the normal case, not an anomaly. There is
|
|
294
|
+
* no `while (true)` and no recursion anywhere on this path.
|
|
254
295
|
*
|
|
255
296
|
* @param {{cfg: object, env: NodeJS.ProcessEnv, toolName: string, args: object,
|
|
256
|
-
* fetchImpl?: typeof fetch, timeoutMs?: number
|
|
297
|
+
* fetchImpl?: typeof fetch, timeoutMs?: number, retry?: object,
|
|
298
|
+
* lockWaitMs?: number}} opts
|
|
257
299
|
* @returns {Promise<object>} the callTool result, plus `authMode` / `authWarning`,
|
|
258
|
-
* or an auth failure shaped like a callTool failure (`authError: true`)
|
|
300
|
+
* or an auth failure shaped like a callTool failure (`authError: true`), or a
|
|
301
|
+
* locally-refused call (`breakerOpen` / `rateCapped`) that never left the machine.
|
|
259
302
|
*/
|
|
260
|
-
export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs }) {
|
|
261
|
-
const
|
|
303
|
+
export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs, retry, lockWaitMs }) {
|
|
304
|
+
const policy = { ...DEFAULT_RETRY, ...(retry ?? {}) };
|
|
305
|
+
|
|
306
|
+
// Cheapest possible check first: an open breaker resolves no credential,
|
|
307
|
+
// opens no socket, and mints no token. That is the entire point — the
|
|
308
|
+
// incident cost ~50 requests a minute for days precisely because a client
|
|
309
|
+
// with no hope of succeeding still asked.
|
|
310
|
+
const gate = breakerGate({ cfg, env });
|
|
311
|
+
if (gate.open) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
status: 0,
|
|
315
|
+
data: breakerNotice(gate),
|
|
316
|
+
raw: null,
|
|
317
|
+
breakerOpen: true,
|
|
318
|
+
breaker: gate,
|
|
319
|
+
authMode: cfg?.authMode ?? 'none',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const slot = reserveCallSlot({ env });
|
|
324
|
+
if (!slot.allowed) {
|
|
325
|
+
return { ok: false, status: 0, data: ceilingNotice(slot), raw: null, rateCapped: true, rateSlot: slot };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const first = await resolveBearer({ cfg, env, fetchImpl, lockWaitMs });
|
|
262
329
|
if (!first.ok) {
|
|
263
|
-
|
|
330
|
+
const breaker = recordCallFailure({ cfg, env, kind: 'auth', code: first.code, detail: first.code });
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
status: 0,
|
|
334
|
+
data: first.message,
|
|
335
|
+
raw: null,
|
|
336
|
+
authError: true,
|
|
337
|
+
authMode: first.mode,
|
|
338
|
+
breaker,
|
|
339
|
+
};
|
|
264
340
|
}
|
|
265
341
|
|
|
266
|
-
|
|
267
|
-
|
|
342
|
+
let token = first.token;
|
|
343
|
+
let mode = first.mode;
|
|
344
|
+
let warning = first.warning;
|
|
345
|
+
// A proactive refresh already spent this call's one re-mint.
|
|
346
|
+
let refreshSpent = first.refreshed;
|
|
347
|
+
let retried = false;
|
|
268
348
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
349
|
+
const hardCap = policy.maxAttempts + 1;
|
|
350
|
+
let attempt = 0;
|
|
351
|
+
let spentDelayMs = 0;
|
|
352
|
+
let res = null;
|
|
272
353
|
|
|
273
|
-
|
|
274
|
-
|
|
354
|
+
while (attempt < hardCap) {
|
|
355
|
+
attempt += 1;
|
|
356
|
+
res = await callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
|
|
357
|
+
res.authMode = mode;
|
|
358
|
+
if (warning) res.authWarning = warning;
|
|
359
|
+
if (retried) res.authRetried = true;
|
|
360
|
+
if (attempt > 1) res.attempts = attempt;
|
|
275
361
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
362
|
+
if (res.ok) break;
|
|
363
|
+
|
|
364
|
+
const kind = classifyFailure(res);
|
|
365
|
+
|
|
366
|
+
// One refresh, paired with the call that needed it — not with each attempt.
|
|
367
|
+
// Re-minting per attempt is what turned a dead credential into two requests
|
|
368
|
+
// per hook instead of one.
|
|
369
|
+
if (kind === 'auth' && mode === 'oauth' && !refreshSpent) {
|
|
370
|
+
refreshSpent = true;
|
|
371
|
+
const second = await resolveBearer({ cfg, env, fetchImpl, force: true, lockWaitMs });
|
|
372
|
+
if (!second.ok) {
|
|
373
|
+
res.authError = true;
|
|
374
|
+
res.authRefreshFailed = second.message;
|
|
375
|
+
res.authCode = second.code;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
token = second.token;
|
|
379
|
+
mode = second.mode;
|
|
380
|
+
warning = second.warning;
|
|
381
|
+
retried = true;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const retryAfterMs = retryAfterMsOf(res);
|
|
386
|
+
const plan = planRetry({ kind, attempt, spentDelayMs, retryAfterMs, policy });
|
|
387
|
+
if (!plan.retry) break;
|
|
388
|
+
await sleep(plan.delayMs);
|
|
389
|
+
spentDelayMs += plan.delayMs;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (res.ok) {
|
|
393
|
+
recordCallSuccess({ cfg, env });
|
|
280
394
|
return res;
|
|
281
395
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
396
|
+
|
|
397
|
+
const kind = classifyFailure(res);
|
|
398
|
+
res.breaker = recordCallFailure({
|
|
399
|
+
cfg,
|
|
400
|
+
env,
|
|
401
|
+
kind,
|
|
402
|
+
code: res.authCode,
|
|
403
|
+
detail: failureDetail(res, kind),
|
|
404
|
+
retryAfterMs: retryAfterMsOf(res),
|
|
405
|
+
});
|
|
406
|
+
return res;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A short, token-free reason to persist alongside the breaker state. */
|
|
410
|
+
function failureDetail(res, kind) {
|
|
411
|
+
if (kind === 'auth' && res.authRefreshFailed) return 'OAuth refresh failed';
|
|
412
|
+
if (res.networkError) return typeof res.data === 'string' ? res.data : 'request failed';
|
|
413
|
+
return `HTTP ${res.status}`;
|
|
287
414
|
}
|
|
288
415
|
|
|
289
416
|
/**
|
|
@@ -308,3 +435,33 @@ export function formatAuthFailure(prefix, res) {
|
|
|
308
435
|
}
|
|
309
436
|
return null;
|
|
310
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The failure line for a call this machine refused to make.
|
|
441
|
+
*
|
|
442
|
+
* `formatCallFailure` would render `server 0 — …` for these, inventing an HTTP
|
|
443
|
+
* exchange that never happened. Both cases are local decisions and must read
|
|
444
|
+
* that way, or the user goes looking for a server problem that is not there.
|
|
445
|
+
*
|
|
446
|
+
* @param {string} prefix
|
|
447
|
+
* @param {object} res
|
|
448
|
+
* @returns {string|null}
|
|
449
|
+
*/
|
|
450
|
+
export function formatLocalRefusal(prefix, res) {
|
|
451
|
+
if (res?.breakerOpen || res?.rateCapped) {
|
|
452
|
+
return `${prefix}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}\n`;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The one failure formatter every capture command uses, so a new failure mode
|
|
459
|
+
* can never reach a command that does not know how to render it.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} prefix
|
|
462
|
+
* @param {object} res
|
|
463
|
+
* @returns {string}
|
|
464
|
+
*/
|
|
465
|
+
export function formatFailure(prefix, res) {
|
|
466
|
+
return formatLocalRefusal(prefix, res) ?? formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res);
|
|
467
|
+
}
|