@anyslate/cli 0.3.1 → 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
@@ -196,6 +196,8 @@ export ANYSLATE_HANDLE=mh_xxxxxxxx # optional, scope to one capab
196
196
  export ANYSLATE_DISABLE=1 # optional, disables capture for this shell
197
197
  export ANYSLATE_HOME=/path/to/dir # optional, overrides ~/.anyslate
198
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)
199
201
  ```
200
202
 
201
203
  Env vars override `~/.anyslate/cli.json`.
@@ -214,6 +216,24 @@ Capability handles are `mh_` + a 32-character id (e.g. `mh_V1StGXR8Z5jdHi6BmyT0a
214
216
 
215
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.
216
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
+
217
237
  ### The two URL conventions - the one thing people get wrong
218
238
 
219
239
  AnySlate ships two clients and they want **different** URLs. This trips up nearly everyone:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anyslate/cli",
3
- "version": "0.3.1",
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
+ }
@@ -0,0 +1,178 @@
1
+ // Retry policy — the client half of the 2026-08-02 database overload.
2
+ //
3
+ // The request tail from that outage is ONE machine running anyslate-cli/0.3.1
4
+ // with a stale refresh token, emitting `POST /oauth/token` followed by
5
+ // `POST /mcp` roughly fifty times a minute, for hours. Nothing in the client
6
+ // ever said "wait": an auth failure re-minted a token and re-issued the call
7
+ // immediately, so the only ceiling on request rate was how fast a process could
8
+ // loop. When the database then answered "overloaded", every client retried at
9
+ // once and deepened the overload.
10
+ //
11
+ // Nothing here is about making a call succeed. It is about making failure cheap
12
+ // for the server:
13
+ //
14
+ // * a HARD attempt cap per process — no path in this CLI may loop unbounded,
15
+ // * exponential backoff with jitter between attempts, so a fleet recovering
16
+ // from one outage does not resynchronise into the next one,
17
+ // * `Retry-After` on a 429/503 OVERRIDES our own schedule, and when the
18
+ // server asks for longer than the whole retry budget we do NOT sleep on it —
19
+ // the wait is handed to the circuit breaker (guard.mjs), the process exits,
20
+ // and the *next* process is the one that honours it. Sleeping instead would
21
+ // hold a hook open across the user's editor.
22
+ //
23
+ // Pure module: no I/O, no ambient timers, injectable clock/randomness, so the
24
+ // policy is unit-testable without a server.
25
+
26
+ /**
27
+ * The default budget, used by the user-initiated commands (`checkpoint`,
28
+ * `upload-artifact`) where an exit code is meaningful and a second or two of
29
+ * waiting is cheaper than a lost capture.
30
+ */
31
+ export const DEFAULT_RETRY = {
32
+ maxAttempts: 3,
33
+ baseDelayMs: 500,
34
+ maxDelayMs: 8_000,
35
+ /** Ceiling on time spent sleeping across ALL attempts of one call. */
36
+ maxTotalDelayMs: 15_000,
37
+ /** A `Retry-After` longer than this is never slept on — the breaker holds it. */
38
+ maxRetryAfterSleepMs: 5_000,
39
+ };
40
+
41
+ /**
42
+ * Hooks get NO retries at all.
43
+ *
44
+ * A hook fires on every tool call, in front of the user's editor, and the event
45
+ * it carries is one row in an activity feed. Re-sending it doubles the request
46
+ * rate the incident was made of, in exchange for a row nobody will miss. One
47
+ * attempt, short timeout, move on.
48
+ */
49
+ export const HOOK_RETRY = {
50
+ ...DEFAULT_RETRY,
51
+ maxAttempts: 1,
52
+ maxTotalDelayMs: 0,
53
+ };
54
+
55
+ /**
56
+ * Why a call failed, in the only four buckets that change what we do next.
57
+ *
58
+ * auth — 401/invalid_token. One paired refresh may fix it; repetition
59
+ * never will, so this is what trips the breaker.
60
+ * throttled — 429/503. The server named a wait; honour it, never retry inside it.
61
+ * transient — 5xx, or the request never landed. Worth one backed-off retry.
62
+ * fatal — 4xx we caused (bad payload, unknown kind, denied handle). The
63
+ * server already spent the work; re-sending is pure waste.
64
+ *
65
+ * @param {{ok?: boolean, status?: number, networkError?: boolean}} res a callTool result
66
+ * @returns {'auth'|'throttled'|'transient'|'fatal'|null} null when the call succeeded
67
+ */
68
+ export function classifyFailure(res) {
69
+ if (!res || res.ok) return null;
70
+ if (res.networkError) return 'transient';
71
+ const status = Number(res.status);
72
+ if (status === 401) return 'auth';
73
+ if (status === 429 || status === 503) return 'throttled';
74
+ if (status === 0 || status >= 500) return 'transient';
75
+ return 'fatal';
76
+ }
77
+
78
+ /**
79
+ * The wait the server asked for, in milliseconds.
80
+ *
81
+ * `callTool` already parses `Retry-After` and the body's `retry_after_seconds`
82
+ * into `retryAfterSeconds` (both the transport 429 and the tool-level one), so
83
+ * this only has to normalise it.
84
+ *
85
+ * @param {{retryAfterSeconds?: number|null}} res
86
+ * @returns {number|null}
87
+ */
88
+ export function retryAfterMsOf(res) {
89
+ const seconds = Number(res?.retryAfterSeconds);
90
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
91
+ return Math.round(seconds * 1000);
92
+ }
93
+
94
+ /**
95
+ * Exponential backoff with jitter.
96
+ *
97
+ * Equal jitter (half fixed, half random) rather than full jitter: it still
98
+ * decorrelates a fleet of clients that all failed on the same server-side
99
+ * second, while guaranteeing that attempt N really does wait longer than
100
+ * attempt N-1. Full jitter can draw ~0ms and re-send instantly, which is the
101
+ * behaviour this module exists to remove.
102
+ *
103
+ * @param {number} attempt 1-based; the delay BEFORE attempt+1
104
+ * @param {typeof DEFAULT_RETRY} policy
105
+ * @param {() => number} [random]
106
+ * @returns {number}
107
+ */
108
+ export function backoffDelayMs(attempt, policy = DEFAULT_RETRY, random = Math.random) {
109
+ const exponential = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1);
110
+ const capped = Math.min(policy.maxDelayMs, exponential);
111
+ return Math.round(capped / 2 + random() * (capped / 2));
112
+ }
113
+
114
+ /**
115
+ * The whole retry decision for one failed attempt.
116
+ *
117
+ * Returns `retry: false` for every reason a request must NOT be repeated, and
118
+ * says which one — the caller records that reason, so a machine that stops
119
+ * retrying can explain itself instead of just going quiet.
120
+ *
121
+ * @param {object} opts
122
+ * @param {'auth'|'throttled'|'transient'|'fatal'|null} opts.kind
123
+ * @param {number} opts.attempt how many attempts have already been made
124
+ * @param {number} [opts.spentDelayMs] time already slept on this call
125
+ * @param {number|null} [opts.retryAfterMs]
126
+ * @param {typeof DEFAULT_RETRY} [opts.policy]
127
+ * @param {() => number} [opts.random]
128
+ * @returns {{retry: boolean, delayMs: number, reason: string}}
129
+ */
130
+ export function planRetry({
131
+ kind,
132
+ attempt,
133
+ spentDelayMs = 0,
134
+ retryAfterMs = null,
135
+ policy = DEFAULT_RETRY,
136
+ random = Math.random,
137
+ }) {
138
+ if (kind !== 'throttled' && kind !== 'transient') {
139
+ return { retry: false, delayMs: 0, reason: kind === 'auth' ? 'auth' : 'not_retryable' };
140
+ }
141
+ if (attempt >= policy.maxAttempts) {
142
+ return { retry: false, delayMs: 0, reason: 'attempt_cap' };
143
+ }
144
+
145
+ // A named wait always wins over our schedule — but only if we can afford to
146
+ // sit through it. A server that says "60s" gets an exiting process and a
147
+ // persisted cool-down, not a hook blocking the editor for a minute.
148
+ if (retryAfterMs != null) {
149
+ if (retryAfterMs > policy.maxRetryAfterSleepMs) {
150
+ return { retry: false, delayMs: 0, reason: 'retry_after_too_long' };
151
+ }
152
+ if (spentDelayMs + retryAfterMs > policy.maxTotalDelayMs) {
153
+ return { retry: false, delayMs: 0, reason: 'delay_budget' };
154
+ }
155
+ return { retry: true, delayMs: retryAfterMs, reason: 'retry_after' };
156
+ }
157
+
158
+ const delayMs = backoffDelayMs(attempt, policy, random);
159
+ if (spentDelayMs + delayMs > policy.maxTotalDelayMs) {
160
+ return { retry: false, delayMs: 0, reason: 'delay_budget' };
161
+ }
162
+ return { retry: true, delayMs, reason: 'backoff' };
163
+ }
164
+
165
+ /**
166
+ * Deliberately a REF'd timer. An `unref`ed one lets Node drain its event loop
167
+ * and exit mid-backoff, which would turn "wait, then retry" into "exit 0 having
168
+ * silently dropped the call". The bound on how long this can hold a process is
169
+ * `maxTotalDelayMs`, not the timer flag.
170
+ *
171
+ * @param {number} ms
172
+ * @returns {Promise<void>}
173
+ */
174
+ export function sleep(ms) {
175
+ return new Promise((resolve) => {
176
+ setTimeout(resolve, ms);
177
+ });
178
+ }
@@ -12,8 +12,8 @@
12
12
  // stdout carries results only. Every error goes to stderr.
13
13
 
14
14
  import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
15
- import { formatCallFailure } from '../mcp-client.mjs';
16
- import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
15
+ import { callToolWithAuth, formatFailure } from '../auth.mjs';
16
+ import { markNotified } from '../guard.mjs';
17
17
  import { recordRun } from '../runlog.mjs';
18
18
  import { VERSION } from '../version.mjs';
19
19
  import { makeIo } from '../io.mjs';
@@ -84,8 +84,18 @@ export async function runCheckpoint(argv, deps = {}) {
84
84
  fetchImpl: deps.fetchImpl,
85
85
  });
86
86
  if (res.authWarning) err.write(`${res.authWarning}\n`);
87
+
88
+ // A paused breaker or the local ceiling means no request was made, so there
89
+ // is no round trip to record. Unlike `hook` this is a command the user just
90
+ // typed, so it always says why rather than staying quiet.
91
+ if (res.breakerOpen || res.rateCapped) {
92
+ err.write(formatFailure('anyslate checkpoint', res));
93
+ if (res.breakerOpen) markNotified(env);
94
+ return 1;
95
+ }
96
+
87
97
  if (!res.ok) {
88
- const message = formatAuthFailure('anyslate checkpoint', res) ?? formatCallFailure('anyslate checkpoint', res);
98
+ const message = formatFailure('anyslate checkpoint', res);
89
99
  err.write(message);
90
100
  recordRun(
91
101
  {
@@ -27,6 +27,7 @@ import {
27
27
  } from '../config.mjs';
28
28
  import { checkUrlShape, hasWriteScope, isValidTokenFormat, probeVerify, tokenPreview } from '../verify.mjs';
29
29
  import { hasOauthCredentials, isExpired, minutesUntilExpiry, resolveBearer } from '../auth.mjs';
30
+ import { breakerGate, guardPath } from '../guard.mjs';
30
31
  import { lastRunPath, readLastRun } from '../runlog.mjs';
31
32
  import { callTool } from '../mcp-client.mjs';
32
33
  import { VERSION, USER_AGENT } from '../version.mjs';
@@ -128,6 +129,24 @@ export async function runDoctor(argv = [], deps = {}) {
128
129
  }
129
130
  }
130
131
 
132
+ // ---- 1b. Circuit breaker ------------------------------------------------
133
+ // Pure local state, so it is answerable when the service is down — which is
134
+ // exactly when it will be open. It sits this early because "capture is paused
135
+ // and no request is being made" reframes every check below it: the token may
136
+ // be perfect and capture still dead until the pause expires or `login` runs.
137
+ // `doctor` itself is never gated by it; diagnosing must always be possible.
138
+ const pause = breakerGate({ cfg, env });
139
+ if (pause.open) {
140
+ add(
141
+ WARN,
142
+ 'breaker',
143
+ `Capture is paused until ${pause.until} (${pause.reason}${pause.detail ? `: ${pause.detail}` : ''}) — no requests are being sent.`,
144
+ pause.reason === 'auth'
145
+ ? 'Run `anyslate login` to sign in again; that clears the pause immediately.'
146
+ : `The pause clears itself, or delete ${guardPath(env)} to clear it now.`,
147
+ );
148
+ }
149
+
131
150
  // ---- 2. Token present --------------------------------------------------
132
151
  // "Present" now has three shapes: an env/static token, a live OAuth access
133
152
  // token, or OAuth credentials whose access token has aged out but which carry
@@ -17,10 +17,19 @@
17
17
  // Tool-level errors (HTTP 200 + result.isError) now count as failures and are
18
18
  // therefore visible to --strict — previously a 403 handle denial printed
19
19
  // nothing and exited 0.
20
+ //
21
+ // COST CEILING (2026-08-02). This command is the one the incident was made of:
22
+ // PostToolUse fires per tool call, so a busy session runs it dozens of times a
23
+ // minute, and every run is a fresh process with no memory of the last. It is
24
+ // therefore the strictest caller in the CLI — one attempt, never a retry, a
25
+ // short timeout, and a persisted breaker that makes a hopeless call cost the
26
+ // server nothing at all. A dropped activity row is one line in a feed; a hook
27
+ // that retries is a doubled request rate in front of the user's editor.
20
28
 
21
29
  import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
22
- import { formatCallFailure } from '../mcp-client.mjs';
23
- import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
30
+ import { callToolWithAuth, formatFailure } from '../auth.mjs';
31
+ import { HOOK_RETRY } from '../backoff.mjs';
32
+ import { markNotified } from '../guard.mjs';
24
33
  import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
25
34
  import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
26
35
  import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
@@ -29,6 +38,27 @@ import { makeIo } from '../io.mjs';
29
38
 
30
39
  const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
31
40
 
41
+ /**
42
+ * Total budget for the two requests a submission costs (initialize +
43
+ * tools/call). Deliberately far below the 15s every other command gets: a hook
44
+ * runs in front of a human, and a slow server must not become a slow editor.
45
+ * `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically slow links.
46
+ */
47
+ export const HOOK_TIMEOUT_MS = 5_000;
48
+
49
+ /**
50
+ * How long a hook will queue behind another process's token refresh. The shared
51
+ * default is 10s, which on a machine where every refresh is failing means every
52
+ * hook waits the full ten seconds to learn nothing.
53
+ */
54
+ export const HOOK_LOCK_WAIT_MS = 2_000;
55
+
56
+ /** @param {NodeJS.ProcessEnv} env */
57
+ function hookTimeoutMs(env) {
58
+ const raw = Number(env.ANYSLATE_HOOK_TIMEOUT_MS);
59
+ return Number.isFinite(raw) && raw > 0 ? raw : HOOK_TIMEOUT_MS;
60
+ }
61
+
32
62
  /**
33
63
  * @param {string[]} argv arguments after `hook`
34
64
  * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
@@ -107,10 +137,31 @@ export async function runHook(argv, deps = {}) {
107
137
  toolName: 'activity_submit',
108
138
  args,
109
139
  fetchImpl: deps.fetchImpl,
140
+ timeoutMs: hookTimeoutMs(env),
141
+ retry: HOOK_RETRY,
142
+ lockWaitMs: HOOK_LOCK_WAIT_MS,
110
143
  });
111
144
  if (res.authWarning) err.write(`${res.authWarning}\n`);
145
+
146
+ // A locally-refused call made no request, so there is no run outcome to
147
+ // record — the run log is a record of round trips, and filling it with
148
+ // "we did not try" would bury the failure that caused the pause. The user
149
+ // is told once per pause; after that a paused hook is completely silent,
150
+ // which is the whole contract: cost the server nothing, cost the user
151
+ // nothing, keep the fix on screen exactly once.
152
+ if (res.breakerOpen || res.rateCapped) {
153
+ if (!res.breakerOpen || res.breaker?.noticeDue) {
154
+ err.write(formatFailure(prefix, res));
155
+ if (res.breakerOpen) {
156
+ markNotified(env);
157
+ escalatePause(out, sub, res);
158
+ }
159
+ }
160
+ return strict ? 1 : 0;
161
+ }
162
+
112
163
  if (!res.ok) {
113
- return fail(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
164
+ return fail(formatFailure(prefix, res), {
114
165
  status: res.status,
115
166
  isError: !!res.isError,
116
167
  networkError: !!res.networkError,
@@ -142,6 +193,23 @@ function escalateIfNeeded(out, sub, state, error) {
142
193
  out.write(escalationPayload({ ...state, error }));
143
194
  }
144
195
 
196
+ /**
197
+ * A paused breaker is the one state where stderr is guaranteed to be read by
198
+ * nobody AND the fix is a single command. Say it through the channel Claude
199
+ * Code renders, once, at the only point in a session where acting on it is
200
+ * cheap.
201
+ */
202
+ function escalatePause(out, sub, res) {
203
+ if (sub !== 'session-start') return;
204
+ out.write(
205
+ escalationPayload({
206
+ consecutiveFailures: 0,
207
+ failingSince: null,
208
+ error: typeof res.data === 'string' ? res.data : 'capture paused',
209
+ }),
210
+ );
211
+ }
212
+
145
213
  /** @param {string[]} argv */
146
214
  function parseFlags(argv) {
147
215
  const out = { strict: false };
@@ -26,6 +26,7 @@ import { join } from 'node:path';
26
26
  import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
27
27
  import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
28
28
  import { readConfigFile, writeConfigFile } from '../credentials.mjs';
29
+ import { clearBreaker } from '../guard.mjs';
29
30
  import {
30
31
  DEFAULT_CALLBACK_TIMEOUT_S,
31
32
  REGISTERED_REDIRECT_URI,
@@ -199,6 +200,10 @@ async function runTokenLogin(flags, deps) {
199
200
  err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
200
201
  return 1;
201
202
  }
203
+ // A pause earned by the credential the user has just replaced must not
204
+ // outlive it — otherwise the documented fix ("run `anyslate login`") appears
205
+ // to do nothing for the next fifteen minutes.
206
+ clearBreaker(env);
202
207
 
203
208
  out.write(`anyslate: wrote ${path}\n`);
204
209
  out.write(` apiUrl: ${next.apiUrl}\n`);
@@ -386,6 +391,8 @@ async function runOauthLogin(flags, deps) {
386
391
  err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
387
392
  return 1;
388
393
  }
394
+ // See the static path: a fresh sign-in cancels the pause its predecessor earned.
395
+ clearBreaker(env);
389
396
 
390
397
  out.write(`anyslate: wrote ${path}\n`);
391
398
  out.write(` apiUrl: ${root}\n`);
@@ -22,6 +22,7 @@
22
22
  import { join } from 'node:path';
23
23
  import { anyslateDir, loadConfig } from '../config.mjs';
24
24
  import { readConfigFile, writeConfigFile } from '../credentials.mjs';
25
+ import { clearBreaker } from '../guard.mjs';
25
26
  import { discover, revocationEndpointFor, revokeToken } from '../oauth.mjs';
26
27
  import { makeIo } from '../io.mjs';
27
28
 
@@ -101,6 +102,9 @@ export async function runLogout(argv = [], deps = {}) {
101
102
  err.write(`anyslate logout: could not update ${path} (${e?.message ?? e})\n`);
102
103
  return 1;
103
104
  }
105
+ // The breaker is state about a credential that no longer exists. Leaving it
106
+ // behind would make the next `login` look broken.
107
+ clearBreaker(env);
104
108
 
105
109
  const cleared = [hadOauth && 'OAuth session', hadStatic && 'static token'].filter(Boolean).join(' and ');
106
110
  out.write(`anyslate: cleared the ${cleared} from ${path}.\n`);
@@ -16,8 +16,8 @@
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { basename } from 'node:path';
18
18
  import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
19
- import { formatCallFailure } from '../mcp-client.mjs';
20
- import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
19
+ import { callToolWithAuth, formatFailure } from '../auth.mjs';
20
+ import { markNotified } from '../guard.mjs';
21
21
  import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
22
22
  import { recordRun } from '../runlog.mjs';
23
23
  import { VERSION } from '../version.mjs';
@@ -143,9 +143,17 @@ export async function runUploadArtifact(argv, deps = {}) {
143
143
  fetchImpl: deps.fetchImpl,
144
144
  });
145
145
  if (res.authWarning) err.write(`${res.authWarning}\n`);
146
+
147
+ // No request left the machine, so nothing is recorded as a round trip. See
148
+ // the same branch in checkpoint.mjs.
149
+ if (res.breakerOpen || res.rateCapped) {
150
+ err.write(formatFailure('anyslate upload-artifact', res));
151
+ if (res.breakerOpen) markNotified(env);
152
+ return 1;
153
+ }
154
+
146
155
  if (!res.ok) {
147
- const message =
148
- formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
156
+ const message = formatFailure('anyslate upload-artifact', res);
149
157
  err.write(message);
150
158
  recordRun(
151
159
  {
@@ -40,12 +40,21 @@ export function cliConfigPath(env = process.env) {
40
40
  }
41
41
 
42
42
  /**
43
+ * Read any JSON file out of `~/.anyslate`, tolerating every way it can be
44
+ * missing or malformed.
45
+ *
46
+ * Generic because the credential file is no longer the only state the CLI keeps
47
+ * across processes: the circuit breaker (guard.mjs) has to survive a process
48
+ * exit for the same reason a refresh token does, and it must inherit the same
49
+ * atomic-write discipline rather than growing its own.
50
+ *
51
+ * @param {string} name file name inside the AnySlate directory
43
52
  * @param {NodeJS.ProcessEnv} [env]
44
53
  * @returns {Record<string, any>}
45
54
  */
46
- export function readConfigFile(env = process.env) {
55
+ export function readJsonFile(name, env = process.env) {
47
56
  try {
48
- const parsed = JSON.parse(readFileSync(cliConfigPath(env), 'utf8'));
57
+ const parsed = JSON.parse(readFileSync(join(anyslateDir(env), name), 'utf8'));
49
58
  return parsed && typeof parsed === 'object' ? parsed : {};
50
59
  } catch {
51
60
  return {};
@@ -55,15 +64,16 @@ export function readConfigFile(env = process.env) {
55
64
  /**
56
65
  * Serialize + rename. Never writes the target path in place.
57
66
  *
67
+ * @param {string} name
58
68
  * @param {Record<string, any>} next
59
69
  * @param {NodeJS.ProcessEnv} [env]
60
70
  * @returns {string} the path written
61
71
  */
62
- export function writeConfigFile(next, env = process.env) {
72
+ export function writeJsonFile(name, next, env = process.env) {
63
73
  const dir = anyslateDir(env);
64
74
  mkdirSync(dir, { recursive: true, mode: 0o700 });
65
- const path = join(dir, CONFIG_FILE);
66
- const tmp = join(dir, `${CONFIG_FILE}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
75
+ const path = join(dir, name);
76
+ const tmp = join(dir, `${name}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
67
77
  try {
68
78
  writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
69
79
  renameSync(tmp, path);
@@ -78,6 +88,23 @@ export function writeConfigFile(next, env = process.env) {
78
88
  return path;
79
89
  }
80
90
 
91
+ /**
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {Record<string, any>}
94
+ */
95
+ export function readConfigFile(env = process.env) {
96
+ return readJsonFile(CONFIG_FILE, env);
97
+ }
98
+
99
+ /**
100
+ * @param {Record<string, any>} next
101
+ * @param {NodeJS.ProcessEnv} [env]
102
+ * @returns {string} the path written
103
+ */
104
+ export function writeConfigFile(next, env = process.env) {
105
+ return writeJsonFile(CONFIG_FILE, next, env);
106
+ }
107
+
81
108
  /**
82
109
  * Read-modify-write. `mutate` receives the CURRENT on-disk object (not a
83
110
  * snapshot the caller took earlier) and returns the object to persist.
package/src/guard.mjs ADDED
@@ -0,0 +1,381 @@
1
+ // Client-side circuit breaker and local call ceiling — `~/.anyslate/cli-guard.json`.
2
+ //
3
+ // WHY THIS HAS TO BE ON DISK. Every hook invocation is its own process. An
4
+ // in-memory breaker would be reconstructed, empty, fifty times a minute, which
5
+ // is exactly the shape of the 2026-08-02 overload: one machine with a stale
6
+ // refresh token issued `POST /oauth/token` + `POST /mcp` continuously for hours
7
+ // because each new process started with no memory that the last fifty had all
8
+ // been rejected. The breaker is only useful if it OUTLIVES the process.
9
+ //
10
+ // WHAT IT ENFORCES
11
+ // * after repeated 401s (or one definitively dead credential) capture is
12
+ // paused for an escalating period, the user is told ONCE how to fix it
13
+ // (`anyslate login`), and until then the client makes no request at all,
14
+ // * a `Retry-After` from a 429/503 becomes a persisted pause, so the wait is
15
+ // honoured by whichever process runs next rather than by a sleeping hook,
16
+ // * repeated transport failures (the server saying "overloaded") pause too,
17
+ // so a struggling service is not kept under load by its own clients,
18
+ // * a generous per-minute ceiling on calls from this machine, as a backstop
19
+ // against a runaway agent that is succeeding at fifty calls a minute.
20
+ //
21
+ // FINGERPRINTING, SO A FIX IS NOT PUNISHED. The breaker is bound to the
22
+ // (apiUrl, credential) pair that tripped it. Point the CLI at a different
23
+ // service root, swap the token, or run `anyslate login`, and it no longer
24
+ // applies — otherwise "I fixed my config" would still mean fifteen minutes of
25
+ // dead capture. An OAuth session is fingerprinted by its `client_id`, which
26
+ // survives refresh-token rotation; if it did not, every refresh would silently
27
+ // reset the breaker and we would be back to the storm.
28
+ //
29
+ // FAILS OPEN, ALWAYS. Every function here swallows its own I/O errors. A
30
+ // guard file we cannot read or write must never be the reason a user's capture
31
+ // or login stops working — a local cache blip converting into a lockout is a
32
+ // worse failure than the one this module prevents.
33
+
34
+ import { createHash } from 'node:crypto';
35
+ import { unlinkSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { anyslateDir } from './config.mjs';
38
+ import { readJsonFile, writeJsonFile } from './credentials.mjs';
39
+
40
+ export const GUARD_FILE = 'cli-guard.json';
41
+
42
+ /** Consecutive 401s before capture is paused. One is a blip; three is a state. */
43
+ export const AUTH_FAILURES_BEFORE_OPEN = 3;
44
+
45
+ /** Consecutive transport failures before we stop leaning on a struggling host. */
46
+ export const TRANSIENT_FAILURES_BEFORE_OPEN = 5;
47
+
48
+ /**
49
+ * Escalating pauses. A credential that is still dead after 15 minutes is not
50
+ * going to be alive at 16, and the user has already been told what to do — so
51
+ * the client backs further off rather than re-asking every quarter hour.
52
+ */
53
+ export const AUTH_PAUSE_LADDER_MS = [15 * 60_000, 60 * 60_000, 6 * 60 * 60_000, 24 * 60 * 60_000];
54
+ export const TRANSIENT_PAUSE_LADDER_MS = [30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];
55
+
56
+ /** Clamp on a server-named wait: long enough to respect, short enough to recover from. */
57
+ export const MIN_THROTTLE_MS = 1_000;
58
+ export const MAX_THROTTLE_MS = 60 * 60_000;
59
+
60
+ /**
61
+ * Machine-wide ceiling on capture calls per rolling minute. Set well above any
62
+ * human session (a busy Claude Code hour is single-digit calls per minute) —
63
+ * it exists to cap a runaway loop, not to shape normal traffic.
64
+ * `ANYSLATE_MAX_CALLS_PER_MINUTE=0` disables it.
65
+ */
66
+ export const DEFAULT_CALLS_PER_MINUTE = 60;
67
+
68
+ /** Auth failures that no amount of retrying can resolve — pause on the first one. */
69
+ const TERMINAL_AUTH_CODES = new Set([
70
+ 'invalid_grant',
71
+ 'no_refresh_token',
72
+ 'no_oauth_credentials',
73
+ 'no_credentials',
74
+ 'unauthorized_client',
75
+ 'invalid_client',
76
+ ]);
77
+
78
+ export function guardPath(env = process.env) {
79
+ return join(anyslateDir(env), GUARD_FILE);
80
+ }
81
+
82
+ /**
83
+ * @param {NodeJS.ProcessEnv} [env]
84
+ * @returns {Record<string, any>}
85
+ */
86
+ export function readGuard(env = process.env) {
87
+ return readJsonFile(GUARD_FILE, env);
88
+ }
89
+
90
+ /**
91
+ * @param {Record<string, any>} next
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {boolean} whether it landed — callers proceed either way
94
+ */
95
+ export function writeGuard(next, env = process.env) {
96
+ try {
97
+ writeJsonFile(GUARD_FILE, next, env);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Identify the (endpoint, credential) pair a breaker state belongs to, without
106
+ * storing anything a leaked guard file could be used with. Hashed for the same
107
+ * reason the run log is redacted: this file is written to a shared home
108
+ * directory and read by `doctor`.
109
+ *
110
+ * @param {{apiUrl?: string, authMode?: string, oauth?: object|null, mcpToken?: string|null,
111
+ * staticToken?: string|null, sources?: {mcpToken?: string}}} cfg
112
+ * @returns {string}
113
+ */
114
+ export function authFingerprint(cfg) {
115
+ const apiUrl = String(cfg?.apiUrl ?? '');
116
+ let identity = 'none';
117
+ if (cfg?.sources?.mcpToken === 'env' && cfg?.mcpToken) {
118
+ identity = `env:${cfg.mcpToken}`;
119
+ } else if (cfg?.oauth && (cfg.oauth.access_token || cfg.oauth.refresh_token)) {
120
+ // client_id, NOT the tokens: rotation replaces those on every refresh, and a
121
+ // fingerprint that changes hourly is a breaker that never holds.
122
+ identity = `oauth:${cfg.oauth.client_id ?? ''}:${cfg.oauth.root ?? ''}`;
123
+ } else if (cfg?.staticToken || cfg?.mcpToken) {
124
+ identity = `static:${cfg.staticToken ?? cfg.mcpToken}`;
125
+ }
126
+ return createHash('sha256').update(`${apiUrl}|${identity}`).digest('hex').slice(0, 16);
127
+ }
128
+
129
+ /**
130
+ * Is capture paused right now for this config?
131
+ *
132
+ * Pure read — the caller decides whether to speak, then calls `markNotified`.
133
+ * Splitting it that way keeps "tell the user once" honest across processes: the
134
+ * flag is only set once something was actually printed.
135
+ *
136
+ * @param {{cfg: object, env?: NodeJS.ProcessEnv, now?: number}} opts
137
+ * @returns {{open: boolean, reason: string|null, detail: string|null, until: string|null,
138
+ * pausedForMs: number, noticeDue: boolean}}
139
+ */
140
+ export function breakerGate({ cfg, env = process.env, now = Date.now() }) {
141
+ const closed = { open: false, reason: null, detail: null, until: null, pausedForMs: 0, noticeDue: false };
142
+ const state = readGuard(env);
143
+ if (!state.open_until) return closed;
144
+
145
+ // A different endpoint or credential is a different problem; do not serve a
146
+ // stale verdict about a setup the user has since changed.
147
+ if (state.fingerprint && state.fingerprint !== authFingerprint(cfg)) return closed;
148
+
149
+ const until = Date.parse(String(state.open_until));
150
+ if (!Number.isFinite(until) || until <= now) return closed;
151
+
152
+ return {
153
+ open: true,
154
+ reason: state.reason ?? 'auth',
155
+ detail: state.detail ?? null,
156
+ until: new Date(until).toISOString(),
157
+ pausedForMs: until - now,
158
+ noticeDue: !state.notified_at,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * The one line a paused client is allowed to say. Names the fix, names when it
164
+ * will try again, and never repeats itself on the hook path.
165
+ *
166
+ * @param {{reason: string|null, detail: string|null, until: string|null}} gate
167
+ * @returns {string}
168
+ */
169
+ export function breakerNotice(gate) {
170
+ const until = gate.until ? ` until ${gate.until}` : '';
171
+ const detail = gate.detail ? ` (${gate.detail})` : '';
172
+ if (gate.reason === 'auth') {
173
+ return (
174
+ `anyslate: capture paused${until} — repeated authentication failures${detail}. ` +
175
+ 'Run `anyslate login` to sign in again; no requests are sent until then.'
176
+ );
177
+ }
178
+ if (gate.reason === 'throttled') {
179
+ return `anyslate: capture paused${until} — the server asked this client to back off${detail}.`;
180
+ }
181
+ return (
182
+ `anyslate: capture paused${until} — repeated failures reaching the service${detail}. ` +
183
+ 'Run `anyslate doctor` to check it.'
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Record that a call was rejected, and open the breaker if this is a pattern.
189
+ *
190
+ * @param {object} opts
191
+ * @param {object} opts.cfg
192
+ * @param {'auth'|'throttled'|'transient'|'fatal'|null} opts.kind
193
+ * @param {string} [opts.code] OAuth/refresh error code, when there is one
194
+ * @param {string} [opts.detail] short human reason, stored for `doctor`
195
+ * @param {number|null} [opts.retryAfterMs]
196
+ * @param {NodeJS.ProcessEnv} [opts.env]
197
+ * @param {number} [opts.now]
198
+ * @returns {{opened: boolean, open: boolean, reason: string|null, until: string|null, detail: string|null}}
199
+ */
200
+ export function recordCallFailure({ cfg, kind, code, detail, retryAfterMs = null, env = process.env, now = Date.now() }) {
201
+ const quiet = { opened: false, open: false, reason: null, until: null, detail: null };
202
+ // A 4xx we caused is not a reason to stop capturing: the request was cheap to
203
+ // reject and the next one may be well-formed.
204
+ if (kind !== 'auth' && kind !== 'throttled' && kind !== 'transient') return quiet;
205
+
206
+ const fingerprint = authFingerprint(cfg);
207
+ const previous = readGuard(env);
208
+ // Counters belong to one (endpoint, credential) pair; a switch resets them.
209
+ const carried = previous.fingerprint === fingerprint ? previous : {};
210
+
211
+ const next = {
212
+ fingerprint,
213
+ auth_failures: Number(carried.auth_failures) || 0,
214
+ transient_failures: Number(carried.transient_failures) || 0,
215
+ trips: Number(carried.trips) || 0,
216
+ open_until: carried.open_until ?? null,
217
+ reason: carried.reason ?? null,
218
+ detail: carried.detail ?? null,
219
+ notified_at: carried.notified_at ?? null,
220
+ window_started_at: carried.window_started_at ?? null,
221
+ window_calls: Number(carried.window_calls) || 0,
222
+ updated_at: new Date(now).toISOString(),
223
+ };
224
+
225
+ let pauseMs = 0;
226
+ if (kind === 'throttled') {
227
+ // One is enough. `Retry-After` is an instruction, not a hint, and the
228
+ // machine that ignored it is the one that took the database down.
229
+ pauseMs = clamp(retryAfterMs ?? TRANSIENT_PAUSE_LADDER_MS[0], MIN_THROTTLE_MS, MAX_THROTTLE_MS);
230
+ next.reason = 'throttled';
231
+ } else if (kind === 'auth') {
232
+ next.auth_failures += 1;
233
+ const terminal = code ? TERMINAL_AUTH_CODES.has(code) : false;
234
+ if (terminal || next.auth_failures >= AUTH_FAILURES_BEFORE_OPEN) {
235
+ pauseMs = ladder(AUTH_PAUSE_LADDER_MS, next.trips);
236
+ next.reason = 'auth';
237
+ }
238
+ } else {
239
+ next.transient_failures += 1;
240
+ if (next.transient_failures >= TRANSIENT_FAILURES_BEFORE_OPEN) {
241
+ pauseMs = ladder(TRANSIENT_PAUSE_LADDER_MS, next.trips);
242
+ next.reason = 'server';
243
+ }
244
+ }
245
+
246
+ if (!pauseMs) {
247
+ writeGuard(next, env);
248
+ return quiet;
249
+ }
250
+
251
+ next.open_until = new Date(now + pauseMs).toISOString();
252
+ next.detail = detail ? String(detail).slice(0, 200) : (code ?? null);
253
+ next.trips += 1;
254
+ next.auth_failures = 0;
255
+ next.transient_failures = 0;
256
+ // Re-arm the "say it once" flag: a NEW pause deserves a fresh notice.
257
+ next.notified_at = null;
258
+ writeGuard(next, env);
259
+
260
+ return { opened: true, open: true, reason: next.reason, until: next.open_until, detail: next.detail };
261
+ }
262
+
263
+ /**
264
+ * A call landed. Everything the breaker knew is stale.
265
+ *
266
+ * The rolling-minute window is deliberately preserved — a successful call still
267
+ * counts against the machine ceiling.
268
+ *
269
+ * @param {{cfg: object, env?: NodeJS.ProcessEnv}} opts
270
+ */
271
+ export function recordCallSuccess({ cfg, env = process.env }) {
272
+ const previous = readGuard(env);
273
+ if (!previous.open_until && !previous.auth_failures && !previous.transient_failures && !previous.trips) return;
274
+ writeGuard(
275
+ {
276
+ fingerprint: authFingerprint(cfg),
277
+ auth_failures: 0,
278
+ transient_failures: 0,
279
+ trips: 0,
280
+ open_until: null,
281
+ reason: null,
282
+ detail: null,
283
+ notified_at: null,
284
+ window_started_at: previous.window_started_at ?? null,
285
+ window_calls: Number(previous.window_calls) || 0,
286
+ updated_at: new Date().toISOString(),
287
+ },
288
+ env,
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Mark the pause as explained, so the next hook stays quiet.
294
+ * @param {NodeJS.ProcessEnv} [env]
295
+ * @param {number} [now]
296
+ */
297
+ export function markNotified(env = process.env, now = Date.now()) {
298
+ const state = readGuard(env);
299
+ if (!state.open_until) return;
300
+ writeGuard({ ...state, notified_at: new Date(now).toISOString() }, env);
301
+ }
302
+
303
+ /**
304
+ * Drop the breaker entirely. `login` calls this: the user has just proved they
305
+ * can authenticate, so making them wait out a pause earned by the credential
306
+ * they just replaced would be absurd.
307
+ *
308
+ * @param {NodeJS.ProcessEnv} [env]
309
+ */
310
+ export function clearBreaker(env = process.env) {
311
+ try {
312
+ unlinkSync(guardPath(env));
313
+ } catch {
314
+ // Never existed, or the home directory is not ours to write. Either way the
315
+ // breaker reads as closed, which is the fail-open answer.
316
+ }
317
+ }
318
+
319
+ /**
320
+ * The machine-wide ceiling. Read-modify-write, so two hooks racing in the same
321
+ * second can undercount — that is the intended direction to be wrong in: this
322
+ * is a runaway backstop, not an accounting system, and a lost increment lets a
323
+ * legitimate call through rather than dropping it.
324
+ *
325
+ * @param {{env?: NodeJS.ProcessEnv, now?: number, limit?: number}} [opts]
326
+ * @returns {{allowed: boolean, count: number, limit: number, resetInMs: number}}
327
+ */
328
+ export function reserveCallSlot({ env = process.env, now = Date.now(), limit } = {}) {
329
+ const ceiling = limit ?? callsPerMinuteLimit(env);
330
+ if (!ceiling) return { allowed: true, count: 0, limit: 0, resetInMs: 0 };
331
+
332
+ const state = readGuard(env);
333
+ const startedAt = Date.parse(String(state.window_started_at ?? ''));
334
+ const fresh = !Number.isFinite(startedAt) || now - startedAt >= 60_000;
335
+ const windowStart = fresh ? now : startedAt;
336
+ const count = (fresh ? 0 : Number(state.window_calls) || 0) + 1;
337
+
338
+ writeGuard(
339
+ { ...state, window_started_at: new Date(windowStart).toISOString(), window_calls: count },
340
+ env,
341
+ );
342
+
343
+ return {
344
+ allowed: count <= ceiling,
345
+ count,
346
+ limit: ceiling,
347
+ resetInMs: Math.max(0, windowStart + 60_000 - now),
348
+ };
349
+ }
350
+
351
+ /**
352
+ * @param {NodeJS.ProcessEnv} [env]
353
+ * @returns {number} 0 disables the ceiling
354
+ */
355
+ export function callsPerMinuteLimit(env = process.env) {
356
+ const raw = env.ANYSLATE_MAX_CALLS_PER_MINUTE;
357
+ if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CALLS_PER_MINUTE;
358
+ const n = Number(raw);
359
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_CALLS_PER_MINUTE;
360
+ return Math.floor(n);
361
+ }
362
+
363
+ /**
364
+ * @param {{count: number, limit: number, resetInMs: number}} slot
365
+ * @returns {string}
366
+ */
367
+ export function ceilingNotice(slot) {
368
+ return (
369
+ `anyslate: local rate ceiling reached (${slot.count} calls in the last minute, limit ${slot.limit}) — ` +
370
+ `skipping this one for ${Math.ceil(slot.resetInMs / 1000)}s. ` +
371
+ 'Raise or disable it with ANYSLATE_MAX_CALLS_PER_MINUTE.'
372
+ );
373
+ }
374
+
375
+ function ladder(steps, index) {
376
+ return steps[Math.min(index, steps.length - 1)];
377
+ }
378
+
379
+ function clamp(value, min, max) {
380
+ return Math.min(max, Math.max(min, value));
381
+ }
package/src/index.mjs CHANGED
@@ -78,6 +78,18 @@ env:
78
78
  ANYSLATE_DISABLE=1 kill switch: hook / checkpoint / upload-artifact make no
79
79
  network call and exit 0. \`doctor\` and \`login\` still run
80
80
  so you can diagnose and set up while capture is off.
81
+ ANYSLATE_HOOK_TIMEOUT_MS
82
+ total request budget for one \`hook\` submission
83
+ (default 5000). Hooks never retry.
84
+ ANYSLATE_MAX_CALLS_PER_MINUTE
85
+ machine-wide ceiling on capture calls (default 60;
86
+ 0 disables). A backstop against a runaway loop.
87
+
88
+ capture pauses:
89
+ After repeated authentication failures — or when the server answers 429/503
90
+ with a Retry-After — the CLI stops calling entirely and records the pause in
91
+ ~/.anyslate/cli-guard.json. It says so once, then stays silent. \`anyslate
92
+ login\` clears an auth pause immediately; \`anyslate doctor\` always reports one.
81
93
  `;
82
94
 
83
95
  /**
@@ -99,9 +99,20 @@ export function formatErrorPayload(raw) {
99
99
  }
100
100
 
101
101
  /**
102
- * Extract 429 metadata. `Retry-After` was never read and
102
+ * Statuses that carry a server-named wait.
103
+ *
104
+ * 503 joined 429 after 2026-08-02: when the database was overloaded the
105
+ * handlers returned bare 500s, every client read that as "try again now", and
106
+ * the retries deepened the outage. The service now answers 503 + `Retry-After`
107
+ * on that path, so the client has to read it there too — an unread Retry-After
108
+ * is the same as no Retry-After.
109
+ */
110
+ const THROTTLE_STATUSES = new Set([429, 503]);
111
+
112
+ /**
113
+ * Extract throttle metadata. `Retry-After` was never read and
103
114
  * `retry_after_seconds`/`reset_at`/`reason` survived only in `raw`
104
- * (defect #31). Surfacing only — no retry/backoff logic.
115
+ * (defect #31). Surfacing only — backoff.mjs/guard.mjs decide what to do with it.
105
116
  *
106
117
  * @param {Response} res
107
118
  * @param {unknown} raw
@@ -294,7 +305,7 @@ export async function callTool({
294
305
  const formatted = formatErrorPayload(init.raw);
295
306
  const errLike = formatted ?? { message: `initialize failed (HTTP ${init.status})` };
296
307
  const out = { ok: false, status: init.status, data: errLike, raw: init.raw };
297
- if (init.status === 429) {
308
+ if (THROTTLE_STATUSES.has(init.status)) {
298
309
  const rl = extractRateLimit(init.res, init.raw);
299
310
  out.rateLimit = rl;
300
311
  out.retryAfterSeconds = rl.retry_after_seconds;
@@ -322,7 +333,7 @@ export async function callTool({
322
333
 
323
334
  const status = res.status;
324
335
  const raw = await readBody(res);
325
- const rateLimit = status === 429 ? extractRateLimit(res, raw) : null;
336
+ const rateLimit = THROTTLE_STATUSES.has(status) ? extractRateLimit(res, raw) : null;
326
337
 
327
338
  const finish = (out) => {
328
339
  if (rateLimit) {
@@ -356,7 +367,7 @@ export async function callTool({
356
367
  const embeddedStatus = Number.isFinite(parsed.status) ? Number(parsed.status) : status;
357
368
  const data = formatErrorPayload(parsed) ?? parsed.error ?? text ?? 'tool returned isError';
358
369
  const out = { ok: false, status: embeddedStatus, data, raw, isError: true };
359
- if (embeddedStatus === 429) {
370
+ if (THROTTLE_STATUSES.has(embeddedStatus)) {
360
371
  const rl = extractRateLimit(res, parsed);
361
372
  out.rateLimit = rl;
362
373
  out.retryAfterSeconds = rl.retry_after_seconds;