@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.
- package/README.md +42 -4
- package/bin/agent-guild.mjs +8 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +492 -0
- package/src/manager/github-views.mjs +37 -1
- package/src/manager/main.mjs +15 -2
- package/src/manager/server.mjs +13 -1
- package/src/manager/session.mjs +27 -2
- package/src/manager/shell-env.mjs +53 -13
- package/src/manager/uninstall.mjs +7 -2
- package/src/manager/usage.mjs +116 -10
- package/web/app.js +278 -6
- package/web/environment.js +2 -0
- package/web/index.html +28 -0
- package/web/layout.js +36 -0
- package/web/styles.css +25 -3
- package/web/terminal-controls.js +7 -25
package/src/manager/main.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/src/manager/server.mjs
CHANGED
|
@@ -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
|
}
|
package/src/manager/session.mjs
CHANGED
|
@@ -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' &&
|
|
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:
|
|
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
|
-
/**
|
|
28
|
-
export
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
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: { ...
|
|
65
|
+
env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' },
|
|
46
66
|
});
|
|
47
67
|
if (result.error) return null;
|
|
48
|
-
return
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
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
|
-
|
|
72
|
-
|
|
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)) {
|
package/src/manager/usage.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 {
|
|
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
|
}
|