@anyslate/cli 0.3.1 → 0.4.1

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/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;