@oddessentials/agent-guild 0.31.0 → 0.33.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.
@@ -21,11 +21,13 @@ import { resolveBaseEnv, pathReader } from './shell-env.mjs';
21
21
  import { writeReportShims } from './report-shims.mjs';
22
22
  import { SessionHooks } from './session-hooks.mjs';
23
23
  import { launcherPath, spawnManager } from './launch.mjs';
24
+ import { createAutostart } from './autostart.mjs';
24
25
  import {
25
26
  DEFAULT_HOST,
26
27
  PACKAGE_FILE,
27
28
  PACKAGE_NAME,
28
29
  VERSION,
30
+ dataDir,
29
31
  ensureDataDir,
30
32
  loadOrCreateToken,
31
33
  multiplexerStore,
@@ -40,8 +42,8 @@ const rootDir = path.resolve(here, '../..');
40
42
 
41
43
  const VERSION_REFRESH_MS = 60 * 60 * 1000;
42
44
 
43
- /** `version`, `packageFile` and `github` (GitHub's URLs and client id) stand in for the real ones in tests. */
44
- export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {} } = {}) {
45
+ /** `version`, `packageFile`, `github` (GitHub's URLs and client id) and `autostart` stand in for the real ones in tests. */
46
+ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {}, autostart = null } = {}) {
45
47
  // Validate before creating files, processes or timers, so a typo fails startup cleanly.
46
48
  const remoteSettings = loadRemoteAccess(paths.remoteAccess);
47
49
  ensureDataDir();
@@ -82,6 +84,15 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
82
84
  const modelStats = new ModelStats({ registry });
83
85
  const news = new NewsFeed({ registry });
84
86
  const changelog = new Changelog({ latest: () => selfUpdate.latest });
87
+ // A sign-in entry starts a manager with the default data folder, so one
88
+ // kept elsewhere (as tests do) cannot be given one.
89
+ autostart ??= createAutostart({
90
+ script: path.join(rootDir, 'bin', 'agent-guild.mjs'),
91
+ dataDir: dataDir(),
92
+ log: paths.log,
93
+ getPort: () => api.port,
94
+ unavailable: process.env.AGENT_GUILD_HOME ? 'Not available while AGENT_GUILD_HOME sets the data folder.' : null,
95
+ });
85
96
  let closing = null;
86
97
 
87
98
  const refreshVersions = () => {
@@ -143,6 +154,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
143
154
  extraHosts: remoteSettings.config.access.hosts,
144
155
  extraOrigins: remoteSettings.config.access.origins,
145
156
  remoteAccess,
157
+ autostart,
146
158
  notes: createNotesStore(paths.notes),
147
159
  launcher: launcherPath(),
148
160
  onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
@@ -173,6 +185,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
173
185
  });
174
186
  console.log(`[manager] Agent Guild ${version} listening on ${api.url} (pid ${process.pid})`);
175
187
  refreshVersions();
188
+ autostart.refresh().catch((err) => console.warn(`[manager] could not update the sign-in entry: ${err.message}`));
176
189
  return { api, manager, registry, token, shutdown, remoteAccess };
177
190
  }
178
191
 
@@ -115,6 +115,8 @@ export function createManagerServer({
115
115
  extraHosts = [],
116
116
  extraOrigins = [],
117
117
  remoteAccess = null,
118
+ /** Starting the manager at sign-in, or null where it is not offered. */
119
+ autostart = null,
118
120
  /** In-memory when omitted, so a test server never reads the user's notes file. */
119
121
  notes = createNotesStore(),
120
122
  folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
@@ -276,6 +278,15 @@ export function createManagerServer({
276
278
  return sendJson(res, 202, { remoteAccess: operation });
277
279
  }
278
280
 
281
+ if (autostart && route === '/autostart' && method === 'GET') {
282
+ return sendJson(res, 200, { autostart: await autostart.describe() });
283
+ }
284
+ if (autostart && route === '/autostart' && method === 'PUT') {
285
+ const { enabled } = await readJsonBody(req);
286
+ if (typeof enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
287
+ return sendJson(res, 200, { autostart: await autostart.set(enabled) });
288
+ }
289
+
279
290
  if (route === '/info' && method === 'GET') {
280
291
  selfUpdate?.refresh().catch(() => {});
281
292
  return sendJson(res, 200, {
@@ -458,7 +469,7 @@ export function createManagerServer({
458
469
  const folder = manager.resolveCwd(url.searchParams.get('cwd'));
459
470
  return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
460
471
  }
461
- const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls)(?:\/([^/]+))?$/);
472
+ const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls|branches)(?:\/([^/]+))?$/);
462
473
  if (view) {
463
474
  let parts;
464
475
  try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
@@ -472,6 +483,7 @@ export function createManagerServer({
472
483
  if (kind === 'issues' && number !== undefined && method === 'PATCH') {
473
484
  return sendJson(res, 200, { issue: await views.updateIssue(id, owner, name, number, await readJsonBody(req)) });
474
485
  }
486
+ if (kind === 'branches' && number === undefined && method === 'GET') return sendJson(res, 200, await views.branches(id, owner, name, { page: url.searchParams.get('page') ?? '1' }));
475
487
  if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
476
488
  if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
477
489
  }
@@ -23,6 +23,10 @@ const REPORTING_STATES = new Set(['pending', 'active', 'unavailable', 'setup_req
23
23
  const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end', 'running', 'reset']);
24
24
  const MAX_SHELLS = 256;
25
25
  const MAX_ENDED_TASKS = 256;
26
+ // Keys and reports a terminal sends as escape sequences: X10 mouse reports (three raw bytes after ESC [ M), other CSI
27
+ // and SS3 sequences, OSC and DCS strings (to their end, or the next ESC, so a large paste is scanned once), and ESC
28
+ // with one character (Alt+key). Bracketed paste markers are CSI, so pasted text is kept.
29
+ const ESCAPE_SEQUENCE = /\x1b\[M[\s\S]{3}|\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b[\]P][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[\s\S]?/g;
26
30
 
27
31
  /**
28
32
  * Colours reported to programs that query them (OSC 10/11/12), matching the
@@ -91,6 +95,7 @@ export class Session extends EventEmitter {
91
95
  this.reportingTimeoutMs = opts.reportingTimeoutMs ?? 30000;
92
96
  this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
93
97
  this._reportingTimer = null;
98
+ this._typedSinceEnter = false;
94
99
  this.shellDisplayDelayMs = opts.shellDisplayDelayMs ?? 600;
95
100
  this.shells = new Map();
96
101
  this._shellSeq = 0;
@@ -309,12 +314,12 @@ export class Session extends EventEmitter {
309
314
 
310
315
  input(data) {
311
316
  this.write(data);
312
- if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' && /[\r\n]/.test(data)) {
317
+ if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' && this._sendsTypedLine(data)) {
313
318
  this._reportingTimer = setTimeout(() => {
314
319
  if (this.reporting?.state !== 'pending' || this.status !== 'running') return;
315
320
  this.reporting = {
316
321
  state: 'unavailable',
317
- reason: `${this.provider.tool} has not run Agent Guild's reporting hooks yet. That is expected while it signs in or sets up; otherwise its hooks may be turned off, restricted by an administrator, or not trusted for this folder.`,
322
+ reason: `No report from ${this.provider.tool}'s hooks yet. They report once a prompt is sent; that can wait while ${this.provider.tool} signs in or sets up. If a prompt was sent and this stays, its hooks may be turned off, restricted by an administrator, or not trusted for this folder.`,
318
323
  };
319
324
  this._changed();
320
325
  }, this.reportingTimeoutMs);
@@ -322,6 +327,26 @@ export class Session extends EventEmitter {
322
327
  }
323
328
  }
324
329
 
330
+ /**
331
+ * Whether this input sends a line with something typed on it, the way a
332
+ * prompt is sent. An Enter on an empty line, or after only keys that send
333
+ * escape sequences (arrows in a menu, focus and mouse reports), is not a
334
+ * prompt; nor is one after Ctrl+C or Ctrl+U, which clear the line. Text
335
+ * may arrive one key at a time, so what was typed carries over.
336
+ */
337
+ _sendsTypedLine(data) {
338
+ const keys = data.replace(ESCAPE_SEQUENCE, '');
339
+ let sent = false;
340
+ for (const ch of keys) {
341
+ if (ch === '\r' || ch === '\n') {
342
+ sent ||= this._typedSinceEnter;
343
+ this._typedSinceEnter = false;
344
+ } else if (ch === '\x03' || ch === '\x15') this._typedSinceEnter = false;
345
+ else if (ch >= ' ' && ch !== '\x7f') this._typedSinceEnter = true;
346
+ }
347
+ return sent;
348
+ }
349
+
325
350
  _reply(data) {
326
351
  this.write(data);
327
352
  }
@@ -1,10 +1,16 @@
1
- // Recover the user's real PATH on macOS and Linux.
1
+ // Recover the user's real PATH and locale on macOS and Linux.
2
2
  //
3
3
  // An app started from Finder, the Dock, or a login item does not inherit the
4
4
  // PATH that the user's shell profile builds (Homebrew, nvm, ~/.local/bin, ...),
5
5
  // which is exactly where CLI coding tools usually live. Like VS Code, we ask
6
6
  // the user's login shell for its PATH and merge it in.
7
+ //
8
+ // launchd starts a sign-in manager with no locale at all, where Terminal.app
9
+ // would set LANG to the region's UTF-8 locale. Without one, tmux writes
10
+ // non-ASCII characters as underscores, so a macOS manager with no locale
11
+ // takes Terminal.app's, and there a locale the login shell exports wins.
7
12
 
13
+ import fs from 'node:fs';
8
14
  import { execFile, spawn, spawnSync } from 'node:child_process';
9
15
  import path from 'node:path';
10
16
 
@@ -24,17 +30,31 @@ export function mergePathLists(primary, secondary, delimiter = path.delimiter) {
24
30
  return out.join(delimiter);
25
31
  }
26
32
 
27
- /** Pull PATH out of `env` output captured between the two markers. */
28
- export function parsePathFromEnvOutput(stdout) {
33
+ /** The variables that choose a locale, in the order they take effect. */
34
+ export const LOCALE_VARS = ['LC_ALL', 'LC_CTYPE', 'LANG'];
35
+
36
+ /** The non-empty `names` in `env` output captured between the two markers, or null without the markers. */
37
+ export function parseEnvOutput(stdout, names) {
29
38
  if (typeof stdout !== 'string') return null;
30
39
  const start = stdout.indexOf(START);
31
40
  const end = stdout.indexOf(END, start);
32
41
  if (start === -1 || end === -1) return null;
33
- const line = stdout.slice(start + START.length, end).split(/\r?\n/).find((l) => l.startsWith('PATH='));
34
- return line ? line.slice('PATH='.length) : null;
42
+ const found = {};
43
+ for (const line of stdout.slice(start + START.length, end).split(/\r?\n/)) {
44
+ const eq = line.indexOf('=');
45
+ const name = line.slice(0, eq);
46
+ if (eq > 0 && names.includes(name) && line.length > eq + 1 && !(name in found)) found[name] = line.slice(eq + 1);
47
+ }
48
+ return found;
35
49
  }
36
50
 
37
- export function loginShellPath({ shell = process.env.SHELL, timeoutMs = 8000 } = {}) {
51
+ /** Pull PATH out of `env` output captured between the two markers. */
52
+ export function parsePathFromEnvOutput(stdout) {
53
+ return parseEnvOutput(stdout, ['PATH'])?.PATH ?? null;
54
+ }
55
+
56
+ /** PATH and the locale variables the user's login shell exports, or null. */
57
+ export function loginShellEnv({ shell, env = process.env, timeoutMs = 8000 } = {}) {
38
58
  if (process.platform === 'win32' || !shell) return null;
39
59
  // Read the exported PATH from `env` rather than expanding $PATH: fish, for
40
60
  // one, expands "$PATH" to a space-separated list.
@@ -42,10 +62,26 @@ export function loginShellPath({ shell = process.env.SHELL, timeoutMs = 8000 } =
42
62
  encoding: 'utf8',
43
63
  timeout: timeoutMs,
44
64
  stdio: ['ignore', 'pipe', 'ignore'],
45
- env: { ...process.env, AGENT_GUILD_RESOLVING_ENV: '1' },
65
+ env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' },
46
66
  });
47
67
  if (result.error) return null;
48
- return parsePathFromEnvOutput(result.stdout);
68
+ return parseEnvOutput(result.stdout, ['PATH', ...LOCALE_VARS]);
69
+ }
70
+
71
+ function readAppleLocale() {
72
+ const result = spawnSync('/usr/bin/defaults', ['read', '-g', 'AppleLocale'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] });
73
+ return result.error ? null : result.stdout;
74
+ }
75
+
76
+ /**
77
+ * The locale Terminal.app gives its shells: the system region's UTF-8 locale,
78
+ * or en_US.UTF-8 when macOS has none for it. `AppleLocale` may carry a script
79
+ * (`zh-Hans_CN`) and options (`en_US@rg=gbzzzz`), which locale names do not.
80
+ */
81
+ export function macLocale({ read = readAppleLocale, exists = (name) => fs.existsSync(path.join('/usr/share/locale', name)) } = {}) {
82
+ const region = String(read() || '').trim().split('@')[0].replace(/-[A-Za-z]+(?=_)/, '');
83
+ const name = `${region}.UTF-8`;
84
+ return /^[A-Za-z]+_[A-Za-z]+$/.test(region) && exists(name) ? name : 'en_US.UTF-8';
49
85
  }
50
86
 
51
87
  /**
@@ -59,11 +95,15 @@ export function trimPathExt(env, platform = process.platform) {
59
95
  return env;
60
96
  }
61
97
 
62
- export function resolveBaseEnv() {
63
- const env = trimPathExt({ ...process.env });
64
- if (process.env.AGENT_GUILD_SKIP_SHELL_ENV === '1') return env;
65
- const shellPath = loginShellPath();
66
- if (shellPath) env.PATH = mergePathLists(shellPath, env.PATH);
98
+ /** `platform`, `env`, `shellEnv` and `locale` are replaceable in tests. */
99
+ export function resolveBaseEnv({ platform = process.platform, env: source = process.env, shellEnv = loginShellEnv, locale = macLocale } = {}) {
100
+ const env = trimPathExt({ ...source }, platform);
101
+ // Set before asking the shell, so macOS's /etc/zprofile keeps it rather than putting its C.UTF-8 in its place.
102
+ if (platform === 'darwin' && !LOCALE_VARS.some((name) => env[name])) env.LANG = locale();
103
+ if (env.AGENT_GUILD_SKIP_SHELL_ENV === '1') return env;
104
+ const shell = shellEnv({ shell: env.SHELL, env }) || {};
105
+ if (shell.PATH) env.PATH = mergePathLists(shell.PATH, env.PATH);
106
+ if (platform === 'darwin') for (const name of LOCALE_VARS) if (shell[name]) env[name] = shell[name];
67
107
  return env;
68
108
  }
69
109
 
@@ -68,8 +68,11 @@ function launchChain(launcher) {
68
68
  return chain;
69
69
  }
70
70
 
71
- function removeFile(file) {
72
- fs.rmSync(file, { recursive: true, force: true, maxRetries: 3 });
71
+ /** Deletes a file or folder, and a link as the link itself even when its target is already gone. */
72
+ export function removeFile(file) {
73
+ // Some Node releases (24.13.0 among them) silently keep a dangling link that rmSync is asked to force.
74
+ if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(file);
75
+ else fs.rmSync(file, { recursive: true, force: true, maxRetries: 3 });
73
76
  }
74
77
 
75
78
  export function runPlan(
@@ -111,6 +114,8 @@ export function runPlan(
111
114
  } catch (err) {
112
115
  throw Object.assign(err, { file });
113
116
  }
117
+ // Never report a removal that did not happen.
118
+ if (exists(file)) throw Object.assign(new Error('it is still there'), { file });
114
119
  };
115
120
  const prune = (dir) => {
116
121
  for (const name of fs.readdirSync(dir)) {
@@ -139,7 +139,7 @@ export async function fetchClaudeUsage({ accessToken, plan = null, version = nul
139
139
  },
140
140
  signal: AbortSignal.timeout(10000),
141
141
  });
142
- if (!res.ok) throw httpUsageError(res.status, 'sign in again in Claude Code');
142
+ if (!res.ok) throw await httpUsageError(res, 'sign in again in Claude Code');
143
143
  const body = await res.json();
144
144
  const windows = [];
145
145
  for (const [key, label] of [['five_hour', '5-hour'], ['seven_day', '7-day'], ['seven_day_opus', '7-day Opus'], ['seven_day_sonnet', '7-day Sonnet']]) {
@@ -210,7 +210,7 @@ export async function fetchCodexUsage({ accessToken, accountId = null, fetchImpl
210
210
  const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'User-Agent': 'agent-guild' };
211
211
  if (accountId) headers['ChatGPT-Account-Id'] = accountId;
212
212
  const res = await fetchImpl(CODEX_USAGE_URL, { headers, signal: AbortSignal.timeout(10000) });
213
- if (!res.ok) throw httpUsageError(res.status, 'sign in again in Codex CLI');
213
+ if (!res.ok) throw await httpUsageError(res, 'sign in again in Codex CLI');
214
214
  const body = await res.json();
215
215
  const windows = codexWindows(body?.rate_limit ?? body?.rateLimit);
216
216
  // Limits metered apart from the plan's main windows, one entry per
@@ -279,10 +279,94 @@ export async function commandUsage({ command, args = [] }, env, platform = proce
279
279
  return { plan: body.plan ? String(body.plan).slice(0, 40) : null, windows };
280
280
  }
281
281
 
282
- function httpUsageError(status, signInHint) {
283
- if (status === 401 || status === 403) return new UsageError(`usage endpoint refused the sign-in (HTTP ${status}); ${signInHint}`);
284
- if (status === 429) return Object.assign(new UsageError('usage endpoint is rate limiting requests; retrying later'), { rateLimited: true });
285
- return new UsageError(`usage endpoint answered HTTP ${status}`);
282
+ // JWTs, API keys, bearer credentials and other long opaque strings never reach a message or the log.
283
+ const SECRET_LIKE = /\beyJ[\w-]+\.[\w-]+(?:\.[\w-]*)?|\b(?:sk|rk|pk)-[\w-]{8,}|\bBearer\s+\S+|[\w+/=-]{40,}/gi;
284
+
285
+ /** One line of at most `max` characters from a response body, without markup or anything credential-like. */
286
+ export function excerpt(text, max = 200) {
287
+ if (typeof text !== 'string') return '';
288
+ return text
289
+ .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, ' ')
290
+ .replace(/<[^>]*>/g, ' ')
291
+ .replace(SECRET_LIKE, '[redacted]')
292
+ .replace(/\s+/g, ' ')
293
+ .trim()
294
+ .slice(0, max);
295
+ }
296
+
297
+ /** The error message a JSON body carries, in the shapes the vendors use, else null. */
298
+ function jsonMessage(text) {
299
+ let body;
300
+ try { body = JSON.parse(text); } catch { return null; }
301
+ const error = body?.error;
302
+ const message = firstText(error?.message, typeof error === 'string' ? error : null, body?.error_description, body?.detail, body?.message);
303
+ return message ? excerpt(message, 120) || null : null;
304
+ }
305
+
306
+ /** How much of a failed reply is read: enough for any error message or the start of a challenge page. */
307
+ export const ERROR_BODY_LIMIT = 16 * 1024;
308
+
309
+ /**
310
+ * The start of a reply's body as text, at most about `limit` bytes, the rest
311
+ * left unread. A reply-like object without a body stream is read with text().
312
+ */
313
+ export async function readStart(res, limit = ERROR_BODY_LIMIT) {
314
+ const reader = res.body?.getReader?.();
315
+ if (!reader) return typeof res.text === 'function' ? res.text() : '';
316
+ const decoder = new TextDecoder();
317
+ let text = '';
318
+ let size = 0;
319
+ try {
320
+ while (size < limit) {
321
+ const { done, value } = await reader.read();
322
+ if (done) break;
323
+ size += value.byteLength;
324
+ text += decoder.decode(value, { stream: true });
325
+ }
326
+ } finally {
327
+ // Ends the download of whatever is left; the result does not matter.
328
+ reader.cancel().catch(() => {});
329
+ }
330
+ return (text + decoder.decode()).slice(0, limit);
331
+ }
332
+
333
+ /**
334
+ * The error for a failed usage request. A 401 means the token was refused;
335
+ * a 403 that Cloudflare answered means the request was blocked before the
336
+ * token was looked at, usually because of the network it came from, so it
337
+ * gets no sign-in advice. `detail` is what the manager log records.
338
+ */
339
+ async function httpUsageError(res, signInHint) {
340
+ const { status } = res;
341
+ const header = (name) => { try { return res.headers?.get?.(name) ?? null; } catch { return null; } };
342
+ let text = '';
343
+ try { text = await readStart(res); } catch { /* the body is optional */ }
344
+ const server = header('server');
345
+ const detail = { status, server: excerpt(server, 80) || null, ray: excerpt(header('cf-ray'), 80) || null, body: excerpt(text) };
346
+ const error = (message, extra = {}) => Object.assign(new UsageError(message), { detail }, extra);
347
+ if (status === 401) return error(`usage endpoint refused the sign-in (HTTP 401); ${signInHint}`);
348
+ if (status === 403) {
349
+ const blocked = header('cf-mitigated') !== null || (/cloudflare/i.test(server ?? '') && /html/i.test(header('content-type') ?? ''));
350
+ if (blocked) return error('usage endpoint blocked this network (HTTP 403), not a sign-in problem; usually a VPN, proxy or exit node, and it clears by itself');
351
+ const message = jsonMessage(text);
352
+ return error(`usage endpoint refused access (HTTP 403${message ? `: ${message}` : ''}); if this continues, ${signInHint}`);
353
+ }
354
+ if (status === 429) return error('usage endpoint is rate limiting requests; retrying later', { rateLimited: true });
355
+ return error(`usage endpoint answered HTTP ${status}`);
356
+ }
357
+
358
+ /**
359
+ * What the manager log adds to a failed lookup's message, which already
360
+ * names the HTTP status: the reply's headers and body for a vendor failure,
361
+ * '' when there is nothing more to say, and null for a failure that needs
362
+ * no log entry, such as no sign-in.
363
+ */
364
+ function failureDetail(err) {
365
+ if (err.detail) {
366
+ const { server, ray, body } = err.detail;
367
+ return [server && `server=${server}`, ray && `cf-ray=${ray}`, body && `body="${body}"`].filter(Boolean).join(' ');
368
+ }
369
+ return err instanceof UsageError || err.notSignedIn ? null : '';
286
370
  }
287
371
 
288
372
  // ---- monitor ----------------------------------------------------------------
@@ -295,15 +379,19 @@ export class UsageMonitor {
295
379
  * @param {Function} [opts.fetchImpl]
296
380
  * @param {number} [opts.ttlMs]
297
381
  * @param {object} [opts.readers] credential readers, replaceable in tests
382
+ * @param {(line: string) => void} [opts.log] where vendor and network failures are recorded
298
383
  */
299
- constructor({ registry, env = process.env, platform = process.platform, fetchImpl = fetch, ttlMs = USAGE_TTL_MS, readers = {} } = {}) {
384
+ constructor({ registry, env = process.env, platform = process.platform, fetchImpl = fetch, ttlMs = USAGE_TTL_MS, readers = {}, log = console.warn } = {}) {
300
385
  this.registry = registry;
301
386
  this.env = env;
302
387
  this.platform = platform;
303
388
  this.fetchImpl = fetchImpl;
304
389
  this.ttlMs = ttlMs;
305
390
  this.readers = { claude: readClaudeCredentials, codex: readCodexCredentials, ...readers };
391
+ this.log = log;
306
392
  this.cache = new Map();
393
+ /** Account key → the failure last logged for it, so a failure that persists is logged once and its end once. */
394
+ this.logged = new Map();
307
395
  }
308
396
 
309
397
  /** Snapshots for every account of every provider that has a usage source. */
@@ -322,7 +410,8 @@ export class UsageMonitor {
322
410
  const now = Date.now();
323
411
  if (entry?.inflight) return entry.inflight;
324
412
  if (entry && now - entry.at < entry.ttl) return Promise.resolve(entry.snapshot);
325
- const inflight = this._fetch(provider, account).then((snapshot) => {
413
+ const inflight = this._fetch(provider, account).then(({ snapshot, failure }) => {
414
+ this._record(`${provider.id}/${account.id}`, snapshot.error, failure);
326
415
  const ttl = snapshot.rateLimited ? RATE_LIMITED_TTL_MS : this.ttlMs;
327
416
  this.cache.set(key, { snapshot, at: Date.now(), ttl });
328
417
  return snapshot;
@@ -331,6 +420,20 @@ export class UsageMonitor {
331
420
  return inflight;
332
421
  }
333
422
 
423
+ /** Log a vendor or network failure when it starts or changes, and once when lookups succeed again. */
424
+ _record(key, error, failure) {
425
+ const previous = this.logged.get(key);
426
+ if (failure !== null) {
427
+ if (previous === error) return;
428
+ this.logged.set(key, error);
429
+ this.log(`[usage] ${key}: ${error}${failure ? ` (${failure})` : ''}`);
430
+ } else if (previous !== undefined) {
431
+ this.logged.delete(key);
432
+ if (!error) this.log(`[usage] ${key}: usage lookups work again`);
433
+ }
434
+ }
435
+
436
+ /** Resolves to `{ snapshot, failure }`: the snapshot for clients and, for a vendor or network failure, its log detail (else null). */
334
437
  async _fetch(provider, account) {
335
438
  const base = { providerId: provider.id, accountId: account.id, plan: null, windows: [], credits: null, signedIn: null, fetchedAt: new Date().toISOString(), error: null };
336
439
  const env = { ...this.env, ...provider.env, ...account.env };
@@ -352,10 +455,13 @@ export class UsageMonitor {
352
455
  } else {
353
456
  result = await commandUsage(provider.usage, env, this.platform);
354
457
  }
355
- return { ...base, signedIn, ...result };
458
+ return { snapshot: { ...base, signedIn, ...result }, failure: null };
356
459
  } catch (err) {
357
460
  const message = err instanceof UsageError ? err.message : `usage check failed: ${err.name === 'TimeoutError' ? 'timed out' : err.message}`;
358
- return { ...base, signedIn: err.notSignedIn ? false : signedIn, error: message, ...(err.rateLimited ? { rateLimited: true } : {}) };
461
+ return {
462
+ snapshot: { ...base, signedIn: err.notSignedIn ? false : signedIn, error: message, ...(err.rateLimited ? { rateLimited: true } : {}) },
463
+ failure: failureDetail(err),
464
+ };
359
465
  }
360
466
  }
361
467
  }