@anyslate/cli 0.1.0 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  // Minimal JSON-RPC client for the AnySlate MCP service.
2
2
  //
3
3
  // The MCP service speaks the standard MCP JSON-RPC protocol. We only need
4
- // `tools/call` from the CLI that one method covers `activity_submit`,
4
+ // `tools/call` from the CLI - that one method covers `activity_submit`,
5
5
  // `checkpoint_session`, and `upload_artifact`.
6
6
  //
7
7
  // The server replies with `{ jsonrpc: '2.0', id, result | error }`. When the
@@ -9,12 +9,34 @@
9
9
  // JSON-parse it (the AnySlate dispatcher always returns JSON-encoded text).
10
10
  //
11
11
  // Streamable HTTP transport requires every non-initialize request to carry
12
- // an `Mcp-Session-Id` header (mcp-protocol.ts:2566). The CLI is one-shot per
12
+ // an `Mcp-Session-Id` header (mcp-protocol.ts:5825). The CLI is one-shot per
13
13
  // invocation, so we run `initialize` first, capture the server-issued
14
14
  // session id from the response header, and reuse it for the `tools/call`.
15
+ //
16
+ // IMPORTANT (W2): the MCP wire protocol returns **HTTP 200 with
17
+ // `result.isError === true`** for tool-level rejections — 403 handle denial,
18
+ // 400 unsupported kind, 500 insert failure, tool-level 429. Trusting
19
+ // `res.ok` alone reports every one of those as success. `callTool` therefore
20
+ // inspects `result.isError` and maps it to `ok: false` with the embedded
21
+ // status, which is what makes `--strict` and the checkpoint/upload-artifact
22
+ // fail-closed contract actually mean anything.
23
+
24
+ import { USER_AGENT } from './version.mjs';
15
25
 
16
26
  let nextRequestId = 1;
17
27
 
28
+ const MCP_HEADERS = (token, sessionId) => {
29
+ const h = {
30
+ 'content-type': 'application/json',
31
+ authorization: `Bearer ${token}`,
32
+ accept: 'application/json, text/event-stream',
33
+ 'mcp-protocol-version': '2025-06-18',
34
+ 'user-agent': USER_AGENT,
35
+ };
36
+ if (sessionId) h['mcp-session-id'] = sessionId;
37
+ return h;
38
+ };
39
+
18
40
  async function readBody(res) {
19
41
  const ctype = res.headers.get('content-type') || '';
20
42
  // The MCP service may stream JSON-RPC results as text/event-stream
@@ -42,16 +64,153 @@ async function readBody(res) {
42
64
  }
43
65
  }
44
66
 
67
+ /**
68
+ * Render a server error body as a human string, preserving `error_description`.
69
+ *
70
+ * Dropping `error_description` makes *revoked* and *mistyped* indistinguishable
71
+ * (defect #15) — the single most common real-world 401.
72
+ *
73
+ * @param {unknown} raw
74
+ * @returns {string|object|null}
75
+ */
76
+ export function formatErrorPayload(raw) {
77
+ if (raw == null) return null;
78
+ if (typeof raw === 'string') return raw;
79
+ if (typeof raw !== 'object') return String(raw);
80
+
81
+ const desc =
82
+ typeof raw.error_description === 'string'
83
+ ? raw.error_description
84
+ : typeof raw.errorDescription === 'string'
85
+ ? raw.errorDescription
86
+ : null;
87
+ const err = raw.error;
88
+
89
+ if (typeof err === 'string' && err) {
90
+ return desc ? `${err}: ${desc}` : err;
91
+ }
92
+ if (err && typeof err === 'object') {
93
+ // JSON-RPC error object — keep the object so callers can read `.message`.
94
+ return err;
95
+ }
96
+ if (desc) return desc;
97
+ if (typeof raw.message === 'string') return raw.message;
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Extract 429 metadata. `Retry-After` was never read and
103
+ * `retry_after_seconds`/`reset_at`/`reason` survived only in `raw`
104
+ * (defect #31). Surfacing only — no retry/backoff logic.
105
+ *
106
+ * @param {Response} res
107
+ * @param {unknown} raw
108
+ */
109
+ export function extractRateLimit(res, raw) {
110
+ const header = res?.headers?.get ? res.headers.get('retry-after') : null;
111
+ const body = raw && typeof raw === 'object' ? raw : {};
112
+ const fromBody = body.retry_after_seconds ?? body.retryAfterSeconds;
113
+ const parsedHeader = header != null && header !== '' ? Number(header) : NaN;
114
+ const retryAfterSeconds = Number.isFinite(fromBody)
115
+ ? Number(fromBody)
116
+ : Number.isFinite(parsedHeader)
117
+ ? parsedHeader
118
+ : null;
119
+ return {
120
+ retry_after_seconds: retryAfterSeconds,
121
+ retry_after_header: header ?? null,
122
+ reset_at: body.reset_at ?? body.resetAt ?? null,
123
+ reason: body.reason ?? null,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Turn a thrown fetch error into something that names the host and the cause.
129
+ * Previously DNS failure, connection refused and timeout were all
130
+ * `request failed (fetch failed)` (defect #30).
131
+ *
132
+ * @param {unknown} err
133
+ * @param {string} host
134
+ * @param {number} timeoutMs
135
+ * @returns {string}
136
+ */
137
+ /**
138
+ * Undici wraps the real cause: `fetch failed` carries `cause`, which for a
139
+ * refused/multi-address connect is an AggregateError whose `.errors[]` hold
140
+ * the codes. Walk the whole chain, or every connection error looks identical.
141
+ *
142
+ * @param {unknown} err
143
+ * @param {number} [depth]
144
+ * @returns {string|null}
145
+ */
146
+ export function findErrorCode(err, depth = 0) {
147
+ if (!err || depth > 5) return null;
148
+ if (typeof err.code === 'string' && err.code) return err.code;
149
+ if (Array.isArray(err.errors)) {
150
+ for (const inner of err.errors) {
151
+ const code = findErrorCode(inner, depth + 1);
152
+ if (code) return code;
153
+ }
154
+ }
155
+ return findErrorCode(err.cause, depth + 1);
156
+ }
157
+
158
+ export function classifyNetworkError(err, host, timeoutMs) {
159
+ const message = String(err?.message ?? err ?? '');
160
+ const code = findErrorCode(err);
161
+ const seconds = Math.round((timeoutMs ?? 0) / 1000);
162
+
163
+ if (err?.name === 'AbortError' || /aborted/i.test(message)) {
164
+ return `timed out after ${seconds}s contacting ${host}`;
165
+ }
166
+ switch (code) {
167
+ case 'ENOTFOUND':
168
+ return `ENOTFOUND ${host} (DNS)`;
169
+ case 'EAI_AGAIN':
170
+ return `EAI_AGAIN ${host} (DNS)`;
171
+ case 'ECONNREFUSED':
172
+ return `ECONNREFUSED ${host}`;
173
+ case 'ECONNRESET':
174
+ return `ECONNRESET ${host}`;
175
+ case 'ETIMEDOUT':
176
+ case 'UND_ERR_CONNECT_TIMEOUT':
177
+ case 'UND_ERR_HEADERS_TIMEOUT':
178
+ return `timed out contacting ${host} (${code})`;
179
+ default:
180
+ break;
181
+ }
182
+ if (code && /CERT|SSL|TLS/i.test(code)) return `TLS error contacting ${host} (${code})`;
183
+ if (/certificate|self[- ]signed|SSL/i.test(message)) return `TLS error contacting ${host}`;
184
+ if (code) return `${code} ${host}`;
185
+ return `${message || 'request failed'} (${host})`;
186
+ }
187
+
188
+ /**
189
+ * Uniform failure line for every command. Network failures are not "server
190
+ * <status>" failures — printing `server 0` would be a lie.
191
+ *
192
+ * @param {string} prefix e.g. `anyslate hook stop`
193
+ * @param {{status: number, data: unknown, networkError?: boolean}} res
194
+ * @returns {string}
195
+ */
196
+ export function formatCallFailure(prefix, res) {
197
+ const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
198
+ if (res.networkError) return `${prefix}: request failed: ${msg}\n`;
199
+ return `${prefix}: server ${res.status} — ${msg}\n`;
200
+ }
201
+
202
+ function hostOf(url) {
203
+ try {
204
+ return new URL(url).host;
205
+ } catch {
206
+ return url;
207
+ }
208
+ }
209
+
45
210
  async function initializeSession({ url, token, fetchImpl, ac }) {
46
211
  const res = await fetchImpl(url, {
47
212
  method: 'POST',
48
- headers: {
49
- 'content-type': 'application/json',
50
- authorization: `Bearer ${token}`,
51
- accept: 'application/json, text/event-stream',
52
- 'mcp-protocol-version': '2025-06-18',
53
- 'user-agent': 'anyslate-cli/0.1.0',
54
- },
213
+ headers: MCP_HEADERS(token),
55
214
  body: JSON.stringify({
56
215
  jsonrpc: '2.0',
57
216
  id: nextRequestId++,
@@ -59,7 +218,7 @@ async function initializeSession({ url, token, fetchImpl, ac }) {
59
218
  params: {
60
219
  protocolVersion: '2025-06-18',
61
220
  capabilities: {},
62
- clientInfo: { name: 'anyslate-cli', version: '0.1.0' },
221
+ clientInfo: { name: 'anyslate-cli', version: USER_AGENT.split('/')[1] },
63
222
  },
64
223
  }),
65
224
  signal: ac.signal,
@@ -67,45 +226,61 @@ async function initializeSession({ url, token, fetchImpl, ac }) {
67
226
  const sid = res.headers.get('mcp-session-id');
68
227
  if (!res.ok) {
69
228
  const raw = await readBody(res);
70
- return { ok: false, status: res.status, sessionId: null, raw };
229
+ return { ok: false, status: res.status, sessionId: null, raw, res };
71
230
  }
72
231
  if (!sid) {
73
- return { ok: false, status: res.status, sessionId: null, raw: { error: 'server omitted Mcp-Session-Id header on initialize' } };
232
+ return {
233
+ ok: false,
234
+ status: res.status,
235
+ sessionId: null,
236
+ raw: { error: 'server omitted Mcp-Session-Id header on initialize' },
237
+ res,
238
+ };
74
239
  }
75
- // Best-effort `notifications/initialized` server returns 202 and we
240
+ // Best-effort `notifications/initialized` - server returns 202 and we
76
241
  // proceed regardless. Required by spec; harmless if dropped on the floor.
77
242
  try {
78
243
  await fetchImpl(url, {
79
244
  method: 'POST',
80
- headers: {
81
- 'content-type': 'application/json',
82
- authorization: `Bearer ${token}`,
83
- accept: 'application/json, text/event-stream',
84
- 'mcp-protocol-version': '2025-06-18',
85
- 'mcp-session-id': sid,
86
- 'user-agent': 'anyslate-cli/0.1.0',
87
- },
245
+ headers: MCP_HEADERS(token, sid),
88
246
  body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
89
247
  signal: ac.signal,
90
248
  });
91
249
  } catch {
92
- // Swallow we have the session id; the notification is advisory.
250
+ // Swallow - we have the session id; the notification is advisory.
93
251
  }
94
252
  return { ok: true, status: res.status, sessionId: sid, raw: null };
95
253
  }
96
254
 
97
255
  /**
98
256
  * @param {object} opts
99
- * @param {string} opts.apiUrl
257
+ * @param {string} opts.apiUrl service ROOT (the client appends `/mcp`)
100
258
  * @param {string} opts.token Bearer token (MCP token).
101
259
  * @param {string} opts.toolName
102
260
  * @param {Record<string, unknown>} opts.args
103
261
  * @param {typeof fetch} [opts.fetchImpl] override for tests
104
262
  * @param {number} [opts.timeoutMs]
105
- * @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown }>}
263
+ * @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown,
264
+ * isError?: boolean, networkError?: boolean, retryAfterSeconds?: number|null,
265
+ * rateLimit?: object }>}
106
266
  */
107
- export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetch, timeoutMs = 15000, sessionId: presetSessionId }) {
108
- const url = `${apiUrl.replace(/\/+$/, '')}/mcp`;
267
+ export async function callTool({
268
+ apiUrl,
269
+ token,
270
+ toolName,
271
+ args,
272
+ fetchImpl = fetch,
273
+ timeoutMs = 15000,
274
+ sessionId: presetSessionId,
275
+ }) {
276
+ // `apiUrl` is already normalized by loadConfig; strip again defensively so a
277
+ // direct caller cannot reintroduce `/mcp/mcp`.
278
+ const root = String(apiUrl ?? '')
279
+ .trim()
280
+ .replace(/\/+$/, '')
281
+ .replace(/(\/mcp)+$/i, '');
282
+ const url = `${root}/mcp`;
283
+ const host = hostOf(url);
109
284
  const ac = new AbortController();
110
285
  const timer = setTimeout(() => ac.abort(), timeoutMs);
111
286
 
@@ -116,12 +291,16 @@ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetc
116
291
  if (!init.ok) {
117
292
  // Surface the auth/init failure exactly as if the tool call itself failed,
118
293
  // so the CLI's existing error handling (server 401 etc.) keeps working.
119
- const errLike = init.raw && typeof init.raw === 'object' && 'error' in init.raw
120
- ? init.raw.error
121
- : init.raw && typeof init.raw === 'object' && 'message' in init.raw
122
- ? init.raw
123
- : { message: `initialize failed (HTTP ${init.status})` };
124
- return { ok: false, status: init.status, data: errLike, raw: init.raw };
294
+ const formatted = formatErrorPayload(init.raw);
295
+ const errLike = formatted ?? { message: `initialize failed (HTTP ${init.status})` };
296
+ const out = { ok: false, status: init.status, data: errLike, raw: init.raw };
297
+ if (init.status === 429) {
298
+ const rl = extractRateLimit(init.res, init.raw);
299
+ out.rateLimit = rl;
300
+ out.retryAfterSeconds = rl.retry_after_seconds;
301
+ out.data = withRetryHint(errLike, rl);
302
+ }
303
+ return out;
125
304
  }
126
305
  sessionId = init.sessionId;
127
306
  }
@@ -136,39 +315,106 @@ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetc
136
315
 
137
316
  const res = await fetchImpl(url, {
138
317
  method: 'POST',
139
- headers: {
140
- 'content-type': 'application/json',
141
- authorization: `Bearer ${token}`,
142
- accept: 'application/json, text/event-stream',
143
- 'mcp-protocol-version': '2025-06-18',
144
- 'mcp-session-id': sessionId,
145
- 'user-agent': 'anyslate-cli/0.1.0',
146
- },
318
+ headers: MCP_HEADERS(token, sessionId),
147
319
  body: JSON.stringify(body),
148
320
  signal: ac.signal,
149
321
  });
150
322
 
151
323
  const status = res.status;
152
324
  const raw = await readBody(res);
325
+ const rateLimit = status === 429 ? extractRateLimit(res, raw) : null;
153
326
 
327
+ const finish = (out) => {
328
+ if (rateLimit) {
329
+ out.rateLimit = rateLimit;
330
+ out.retryAfterSeconds = rateLimit.retry_after_seconds;
331
+ out.data = withRetryHint(out.data, rateLimit);
332
+ }
333
+ return out;
334
+ };
335
+
336
+ // JSON-RPC transport-level error.
154
337
  if (raw && typeof raw === 'object' && 'error' in raw && raw.error) {
155
- return { ok: false, status, data: raw.error, raw };
338
+ return finish({ ok: false, status, data: formatErrorPayload(raw) ?? raw.error, raw });
156
339
  }
157
340
 
158
- // `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
159
341
  const result = raw && typeof raw === 'object' ? raw.result : null;
342
+
343
+ // ---- Tool-level error: HTTP 200 + result.isError === true (W2). ----
344
+ if (result && typeof result === 'object' && result.isError === true) {
345
+ const content = Array.isArray(result.content) ? result.content[0] : null;
346
+ const text = content && typeof content.text === 'string' ? content.text : null;
347
+ let parsed = null;
348
+ if (text) {
349
+ try {
350
+ parsed = JSON.parse(text);
351
+ } catch {
352
+ parsed = null;
353
+ }
354
+ }
355
+ if (parsed && typeof parsed === 'object') {
356
+ const embeddedStatus = Number.isFinite(parsed.status) ? Number(parsed.status) : status;
357
+ const data = formatErrorPayload(parsed) ?? parsed.error ?? text ?? 'tool returned isError';
358
+ const out = { ok: false, status: embeddedStatus, data, raw, isError: true };
359
+ if (embeddedStatus === 429) {
360
+ const rl = extractRateLimit(res, parsed);
361
+ out.rateLimit = rl;
362
+ out.retryAfterSeconds = rl.retry_after_seconds;
363
+ out.data = withRetryHint(data, rl);
364
+ }
365
+ return out;
366
+ }
367
+ return finish({
368
+ ok: false,
369
+ status,
370
+ data: text ?? 'tool returned isError with no content',
371
+ raw,
372
+ isError: true,
373
+ });
374
+ }
375
+
376
+ // `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
160
377
  const content = result && Array.isArray(result.content) ? result.content[0] : null;
161
378
  if (content && content.type === 'text' && typeof content.text === 'string') {
162
379
  try {
163
380
  const parsed = JSON.parse(content.text);
164
- return { ok: res.ok, status, data: parsed, raw };
381
+ return finish({ ok: res.ok, status, data: parsed, raw });
165
382
  } catch {
166
- return { ok: res.ok, status, data: content.text, raw };
383
+ return finish({ ok: res.ok, status, data: content.text, raw });
167
384
  }
168
385
  }
169
386
 
170
- return { ok: res.ok, status, data: result, raw };
387
+ // Non-2xx with no parseable JSON-RPC body: never print the literal `null`.
388
+ if (!res.ok) {
389
+ return finish({
390
+ ok: false,
391
+ status,
392
+ data: formatErrorPayload(raw) ?? `server returned HTTP ${status} with no JSON-RPC body`,
393
+ raw,
394
+ });
395
+ }
396
+
397
+ return finish({ ok: res.ok, status, data: result, raw });
398
+ } catch (e) {
399
+ return {
400
+ ok: false,
401
+ status: 0,
402
+ data: classifyNetworkError(e, host, timeoutMs),
403
+ raw: null,
404
+ networkError: true,
405
+ };
171
406
  } finally {
172
407
  clearTimeout(timer);
173
408
  }
174
409
  }
410
+
411
+ function withRetryHint(data, rl) {
412
+ if (!rl) return data;
413
+ const bits = [];
414
+ if (rl.retry_after_seconds != null) bits.push(`retry after ${rl.retry_after_seconds}s`);
415
+ if (rl.reset_at) bits.push(`resets at ${rl.reset_at}`);
416
+ if (rl.reason) bits.push(String(rl.reason));
417
+ if (!bits.length) return data;
418
+ const base = typeof data === 'string' ? data : data == null ? 'rate limited' : JSON.stringify(data);
419
+ return `${base} — ${bits.join(', ')}`;
420
+ }
package/src/runlog.mjs ADDED
@@ -0,0 +1,196 @@
1
+ // W7 — persisted observability for a fail-open CLI.
2
+ //
3
+ // Exit 0 is correct policy: a network blip must never break a Claude Code
4
+ // session. But the error strategy was "write to stderr", and Claude Code files
5
+ // `exit 0 + stderr` as `hook_success` — 0 of 4,896 production hook_success
6
+ // records carry stderr. The entire error channel had no receiver.
7
+ //
8
+ // So: persist every run. `~/.anyslate/cli-last-run.json` (mode 0600) plus a
9
+ // capped NDJSON ring log. `anyslate doctor` reads them. Nothing here changes an
10
+ // exit code.
11
+ //
12
+ // REDACTION: the record must never contain token material. Every value is run
13
+ // through `redact()` before it is written, and there is a test asserting the
14
+ // file never contains `as_mcp_` / `as_oauth_`.
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, renameSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { anyslateDir } from './config.mjs';
19
+
20
+ export const LAST_RUN_FILE = 'cli-last-run.json';
21
+ export const RING_LOG_FILE = 'cli-runs.ndjson';
22
+
23
+ /** Escalate only on persistence, not incidence. One failure is a blip. */
24
+ export const ESCALATE_AFTER_CONSECUTIVE_FAILURES = 3;
25
+
26
+ const RING_MAX_ENTRIES = 200;
27
+ const RING_MAX_BYTES = 256 * 1024;
28
+
29
+ const TOKEN_RE = /\b(as_mcp_|as_oauth_)[A-Za-z0-9_-]*/g;
30
+ const BEARER_RE = /\bBearer\s+\S+/gi;
31
+
32
+ /**
33
+ * Strip anything that looks like credential material from arbitrary data.
34
+ * @param {unknown} value
35
+ * @returns {unknown}
36
+ */
37
+ export function redact(value) {
38
+ if (value == null) return value;
39
+ if (typeof value === 'string') {
40
+ return value.replace(TOKEN_RE, '[redacted-token]').replace(BEARER_RE, 'Bearer [redacted]');
41
+ }
42
+ if (Array.isArray(value)) return value.map(redact);
43
+ if (typeof value === 'object') {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(value)) {
46
+ if (/token|secret|authorization|password|bearer/i.test(k)) {
47
+ out[k] = v == null ? v : '[redacted]';
48
+ continue;
49
+ }
50
+ out[k] = redact(v);
51
+ }
52
+ return out;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ export function lastRunPath(env = process.env) {
58
+ return join(anyslateDir(env), LAST_RUN_FILE);
59
+ }
60
+ export function ringLogPath(env = process.env) {
61
+ return join(anyslateDir(env), RING_LOG_FILE);
62
+ }
63
+
64
+ /**
65
+ * @param {NodeJS.ProcessEnv} [env]
66
+ * @returns {object|null}
67
+ */
68
+ export function readLastRun(env = process.env) {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(lastRunPath(env), 'utf8'));
71
+ return parsed && typeof parsed === 'object' ? parsed : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Persist the outcome of one CLI invocation.
79
+ *
80
+ * Never throws — an unwritable home directory must not turn a successful
81
+ * capture into a failure.
82
+ *
83
+ * @param {object} entry
84
+ * @param {string} entry.command
85
+ * @param {boolean} entry.ok
86
+ * @param {string} [entry.apiUrl]
87
+ * @param {number} [entry.status]
88
+ * @param {boolean} [entry.isError]
89
+ * @param {boolean} [entry.networkError]
90
+ * @param {unknown} [entry.error]
91
+ * @param {number} [entry.exitCode]
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {{consecutiveFailures: number, lastSuccessAt: string|null, failingSince: string|null}}
94
+ */
95
+ export function recordRun(entry, env = process.env) {
96
+ const previous = readLastRun(env) || {};
97
+ const ts = new Date().toISOString();
98
+
99
+ const consecutiveFailures = entry.ok
100
+ ? 0
101
+ : Number.isFinite(previous.consecutive_failures)
102
+ ? Number(previous.consecutive_failures) + 1
103
+ : 1;
104
+ const lastSuccessAt = entry.ok ? ts : (previous.last_success_at ?? null);
105
+ const failingSince = entry.ok ? null : (previous.failing_since ?? ts);
106
+
107
+ const record = redact({
108
+ ts,
109
+ command: entry.command,
110
+ ok: !!entry.ok,
111
+ exit_code: entry.exitCode ?? (entry.ok ? 0 : 1),
112
+ api_url: entry.apiUrl ?? null,
113
+ endpoint: entry.apiUrl ? `${String(entry.apiUrl).replace(/\/+$/, '')}/mcp` : null,
114
+ http_status: entry.status ?? null,
115
+ is_error: entry.isError ?? false,
116
+ network_error: entry.networkError ?? false,
117
+ error: entry.error == null ? null : typeof entry.error === 'string' ? entry.error : JSON.stringify(entry.error),
118
+ consecutive_failures: consecutiveFailures,
119
+ last_success_at: lastSuccessAt,
120
+ failing_since: failingSince,
121
+ cli_version: entry.version ?? null,
122
+ });
123
+
124
+ try {
125
+ const dir = anyslateDir(env);
126
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+ writeFileSync(join(dir, LAST_RUN_FILE), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
128
+ appendRing(dir, record);
129
+ } catch {
130
+ // Observability must never be load-bearing.
131
+ }
132
+
133
+ return { consecutiveFailures, lastSuccessAt, failingSince };
134
+ }
135
+
136
+ function appendRing(dir, record) {
137
+ const path = join(dir, RING_LOG_FILE);
138
+ try {
139
+ let size = 0;
140
+ try {
141
+ size = statSync(path).size;
142
+ } catch {
143
+ size = 0;
144
+ }
145
+ if (size > RING_MAX_BYTES) {
146
+ // Cheap rotation: keep the newest half.
147
+ try {
148
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
149
+ const keep = lines.slice(-Math.floor(RING_MAX_ENTRIES / 2));
150
+ writeFileSync(`${path}.tmp`, `${keep.join('\n')}\n`, { mode: 0o600 });
151
+ renameSync(`${path}.tmp`, path);
152
+ } catch {
153
+ writeFileSync(path, '', { mode: 0o600 });
154
+ }
155
+ }
156
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
157
+ } catch {
158
+ // ignore
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Should a SessionStart hook escalate to the model/user?
164
+ *
165
+ * @param {{consecutiveFailures: number, failingSince: string|null}} state
166
+ * @returns {boolean}
167
+ */
168
+ export function shouldEscalate(state) {
169
+ if (!state) return false;
170
+ if (state.consecutiveFailures >= ESCALATE_AFTER_CONSECUTIVE_FAILURES) return true;
171
+ if (state.failingSince) {
172
+ const since = Date.parse(state.failingSince);
173
+ if (Number.isFinite(since) && Date.now() - since > 24 * 60 * 60 * 1000) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /**
179
+ * The structured stdout block Claude Code actually renders.
180
+ * `hook_system_message` and `hook_additional_context` are both verified working
181
+ * channels; plain stderr is not.
182
+ *
183
+ * @param {{consecutiveFailures: number, failingSince: string|null, error?: string|null}} state
184
+ * @returns {string}
185
+ */
186
+ export function escalationPayload(state) {
187
+ const since = state.failingSince ? ` since ${String(state.failingSince).slice(0, 10)}` : '';
188
+ const because = state.error ? ` (${state.error})` : '';
189
+ return `${JSON.stringify({
190
+ hookSpecificOutput: {
191
+ hookEventName: 'SessionStart',
192
+ additionalContext: `AnySlate capture has been failing${since}${because}. Run \`anyslate doctor\`.`,
193
+ },
194
+ systemMessage: 'AnySlate capture is failing — run `anyslate doctor`.',
195
+ })}\n`;
196
+ }