@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 CHANGED
@@ -1,39 +1,92 @@
1
- # @anyslate/cli
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
- The bridge that lets your AI tools quietly tell AnySlate what you've been working on - without you having to remember to checkpoint anything.
8
+ <h1 align="center">@anyslate/cli</h1>
4
9
 
5
- ## What this is
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
- A small command-line tool that runs on your machine. Your AI tools (Claude Code, git, CI) call it on your behalf as you work. It records what's happening into your AnySlate **Activity feed** - file edits, shell commands, commits - automatically and continuously.
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
- Think of it like a Fitbit, but for your AI work. You don't tell a Fitbit "I just took 47 steps." You wear it, walk around, the count updates. Same shape here. You install this once, paste a small config snippet into your AI tool's settings, and from that point on, capture happens in the background while you work.
22
+ ---
10
23
 
11
- **You almost never run a CLI command yourself.** The tool exists to be invisible plumbing that other tools call. The setup below takes 5 minutes and ends with `anyslate doctor` telling you it works.
24
+ ## What is this?
12
25
 
13
- ## Where it fits in your workflow
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
- Most days you'll never type `anyslate` yourself. Here's what actually happens:
30
+ **This CLI is how that memory gets filled in when no AI is watching.**
16
31
 
17
- | Trigger | Who calls the CLI | What lands |
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 | Claude Code's `SessionStart` hook | Session-start activity; the AnySlate session is created on first sight |
20
- | Claude edits `routes/auth.ts` | Claude Code's `PostToolUse` hook | "Edited routes/auth.ts" in the activity ledger |
21
- | Claude runs `npm test` | Claude Code's `PostToolUse` hook | Command + output in the activity ledger |
22
- | You `git commit` | Git's `post-commit` hook | Commit metadata + diff stats captured |
23
- | Teammate merges your PR | GitHub webhook (no CLI needed) | PR-merged event captured |
24
- | You close the laptop | Claude Code's `Stop` hook | Session-end marker captured |
25
- | **You manually want to checkpoint a decision** | **You typing `anyslate checkpoint ...`** | **Captured immediately** |
26
- | **You want to upload a file as an artifact** | **You typing `anyslate upload-artifact ...`** | **File stored, returns `cloud://artifact/<id>`** |
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 - that's what the lifecycle hooks do. The bottom two are the rare moments where you'd actually type something yourself.
75
+ The first six rows are automatic. The last two are the rare moments you'd type something.
29
76
 
30
- ### What hook captures actually write - read this before you form expectations
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: which files were touched, which commands ran, what exit status they returned. That is the whole contract. The ledger is capped (25 files / 15 commands per entry) and is built deterministically - no LLM runs in the hook path, so capture never bills against your AI quota.
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 the **Decisions** or **Open Tasks** sections. Those stay human- and LLM-authored, and are populated by `anyslate checkpoint`, by the in-host MCP flow (`/anyslate-new`, `/anyslate-continue`), or by you editing the page. A hook firing on `Bash` has no decision in it to extract, and the product no longer pretends otherwise.
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, type it:
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 - `Edit` / `Write` / `MultiEdit`, `Bash`, and the session start/stop markers - classifies **low-risk** and auto-promotes on its own, 30-90 seconds after capture. An edit-heavy session does **not** build an approval queue.
45
-
46
- Rows still wait for approval in **AI Memory → Activity** when they are genuinely riskier: a capture aimed at a different session than the one that produced it, a decision that supersedes an earlier one, a submission from an untrusted source, or one carrying `confidence < 0.8`.
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
- ## What you get back
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
- After the setup is in place:
106
+ ## Why bother
53
107
 
54
108
  - **You stop forgetting to checkpoint.** Capture happens whether you remember or not.
55
- - **AnySlate sees what actually happened, not just what the AI claimed.** Git knows what files really changed. The hooks know what commands really ran.
56
- - **Your work follows you across tools.** Claude Code at home, Cursor at work, terminal in between - all feed the same memory. (Cursor and Windsurf feed it through the in-host MCP server, not this CLI - see the FAQ.)
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 are idempotent — they preserve the `apiUrl` and `handle` you already had (and re-normalize a stored `apiUrl` that carries a stale `/mcp` suffix).
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.3.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 15s
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, now?: number}} opts
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 auth lifecycle wrapped around it: proactive refresh, then
250
- * at most one refresh-and-retry on a 401.
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
- * The retry is gated on `mode === 'oauth'`: a static token that 401s is revoked
253
- * or mistyped, and re-sending it cannot help.
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}} opts
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 first = await resolveBearer({ cfg, env, fetchImpl });
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
- return { ok: false, status: 0, data: first.message, raw: null, authError: true, authMode: first.mode };
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
- const invoke = (token) =>
267
- callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
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
- let res = await invoke(first.token);
270
- res.authMode = first.mode;
271
- if (first.warning) res.authWarning = first.warning;
349
+ const hardCap = policy.maxAttempts + 1;
350
+ let attempt = 0;
351
+ let spentDelayMs = 0;
352
+ let res = null;
272
353
 
273
- const retryable = !res.ok && res.status === 401 && first.mode === 'oauth' && !first.refreshed;
274
- if (!retryable) return res;
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
- const second = await resolveBearer({ cfg, env, fetchImpl, force: true });
277
- if (!second.ok) {
278
- res.authError = true;
279
- res.authRefreshFailed = second.message;
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
- const retried = await invoke(second.token);
283
- retried.authMode = second.mode;
284
- retried.authRetried = true;
285
- if (second.warning) retried.authWarning = second.warning;
286
- return retried;
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
+ }