@sagentlab/navarch-runtime 0.1.14 → 0.1.17
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 +24 -8
- package/dist/api.cjs +48 -21
- package/dist/cli.cjs +8 -2
- package/dist/config.cjs +15 -0
- package/dist/exit-conditions.cjs +1 -1
- package/dist/git-worktree.cjs +22 -0
- package/dist/github-pr.cjs +50 -30
- package/dist/heartbeat-loop.cjs +44 -16
- package/dist/session.cjs +157 -29
- package/dist/upload.cjs +18 -1
- package/dist/usage-limits.cjs +400 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@ it. Plain Node/TypeScript, zero production dependencies, no Next.js coupling
|
|
|
6
6
|
— this directory is a self-contained package you can `npx` on any fresh
|
|
7
7
|
machine.
|
|
8
8
|
|
|
9
|
-
The
|
|
10
|
-
Gemini CLI**
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
The machine operator selects **Claude Code**, **OpenAI Codex**, or **Google
|
|
10
|
+
Gemini CLI** when connecting a worker. Any selected agent can run any project
|
|
11
|
+
task; task eligibility depends on capabilities and project gates, not agent
|
|
12
|
+
type — see "Choosing an agent" below.
|
|
13
13
|
|
|
14
14
|
See [`docs/agent-platform-project-plan.md`](../docs/agent-platform-project-plan.md)
|
|
15
15
|
§3.8/§3.9/§3.11 and [`docs/navarch/implementation-plan.md`](../docs/navarch/implementation-plan.md)
|
|
@@ -74,6 +74,22 @@ the runtime. The UI-generated npm commands remain the source of truth for the
|
|
|
74
74
|
token, project, agent, capacity, API origin, and config directory; translate
|
|
75
75
|
those same options to `node bin/navarch.cjs` for a source install.
|
|
76
76
|
|
|
77
|
+
### Releasing a runtime change
|
|
78
|
+
|
|
79
|
+
Publication is triggered by the version, not by the code: a push to `main` that
|
|
80
|
+
changes `runtime/package.json` publishes that exact version of
|
|
81
|
+
`@sagentlab/navarch-runtime` and rolls it out. So a pull request that edits
|
|
82
|
+
`runtime/src/**` or `runtime/bin/**` without bumping the version merges green
|
|
83
|
+
and never reaches a machine. Bump `runtime/package.json` and
|
|
84
|
+
`runtime/package-lock.json` to the same new version in the pull request that
|
|
85
|
+
carries the change; CI's `Runtime` job fails the pull request when that bump is
|
|
86
|
+
missing, when the two manifests disagree, or when the version is one the base
|
|
87
|
+
branch tip or npm already carries.
|
|
88
|
+
|
|
89
|
+
[`playbooks/runtime-releases.md`](../playbooks/runtime-releases.md) is the full
|
|
90
|
+
release contract — the guard's exact cases, the post-merge duplicate backstop,
|
|
91
|
+
and the recovery steps.
|
|
92
|
+
|
|
77
93
|
The operator-only `register` command enrolls a globally managed machine. It is
|
|
78
94
|
not the normal public onboarding path. `register` prints the machine auth token
|
|
79
95
|
once and stores the same `machine.json` identity used by `connect`. For
|
|
@@ -354,15 +370,15 @@ export NAVARCH_AGENT=codex
|
|
|
354
370
|
# NAVARCH_GEMINI_BIN pointing at it).
|
|
355
371
|
export NAVARCH_AGENT=gemini
|
|
356
372
|
|
|
357
|
-
#
|
|
373
|
+
# Advanced compatibility mode: advertise every installed adapter.
|
|
358
374
|
export NAVARCH_RUNTIMES=claude-code,codex,gemini
|
|
359
375
|
```
|
|
360
376
|
|
|
361
377
|
For the legacy single-runtime setting, priority is `start --agent` →
|
|
362
378
|
`NAVARCH_AGENT` → the locally saved choice → `claude-code`.
|
|
363
|
-
`NAVARCH_RUNTIMES` expands what the worker advertises
|
|
364
|
-
|
|
365
|
-
|
|
379
|
+
`NAVARCH_RUNTIMES` expands what the worker advertises. For normal projects,
|
|
380
|
+
the locally selected `NAVARCH_AGENT` handles every claimed task. Sandbox
|
|
381
|
+
projects remain constrained to Claude Code.
|
|
366
382
|
|
|
367
383
|
All three adapters implement the same `AgentAdapter` interface
|
|
368
384
|
(`src/adapters/types.cts`) and run either directly on the host or via
|
package/dist/api.cjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.NavarchApiClient = exports.NavarchTransportError = exports.NavarchApiError = void 0;
|
|
4
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
5
|
+
const MAX_ERROR_CAUSE_DEPTH = 4;
|
|
6
|
+
const MAX_AGGREGATE_ERRORS = 4;
|
|
4
7
|
class NavarchApiError extends Error {
|
|
5
8
|
status;
|
|
6
9
|
body;
|
|
@@ -34,10 +37,12 @@ class NavarchApiClient {
|
|
|
34
37
|
baseUrl;
|
|
35
38
|
token;
|
|
36
39
|
fetchImpl;
|
|
40
|
+
requestTimeoutMs;
|
|
37
41
|
constructor(opts) {
|
|
38
42
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
39
43
|
this.token = opts.token;
|
|
40
44
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
45
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
41
46
|
}
|
|
42
47
|
/** The control-plane base URL this client talks to (used to build the per-session platform MCP config -- see mcp-config.cts). */
|
|
43
48
|
getBaseUrl() {
|
|
@@ -57,29 +62,41 @@ class NavarchApiClient {
|
|
|
57
62
|
headers.authorization = `Bearer ${this.token}`;
|
|
58
63
|
}
|
|
59
64
|
const requestUrl = `${this.baseUrl}${pathname}`;
|
|
60
|
-
|
|
65
|
+
const abortController = new AbortController();
|
|
66
|
+
const requestTimeout = setTimeout(() => {
|
|
67
|
+
const timeoutError = new Error(`request timed out after ${this.requestTimeoutMs}ms`);
|
|
68
|
+
timeoutError.name = "TimeoutError";
|
|
69
|
+
abortController.abort(timeoutError);
|
|
70
|
+
}, this.requestTimeoutMs);
|
|
61
71
|
try {
|
|
62
|
-
response = await this.fetchImpl(requestUrl, {
|
|
72
|
+
const response = await this.fetchImpl(requestUrl, {
|
|
63
73
|
method,
|
|
64
74
|
headers,
|
|
65
75
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
76
|
+
signal: abortController.signal,
|
|
66
77
|
});
|
|
78
|
+
if (response.status === 204)
|
|
79
|
+
return null;
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
const parsed = text ? safeJsonParse(text) : null;
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new NavarchApiError(`Navarch API ${method} ${pathname} failed with ${response.status}`, response.status, parsed ?? text);
|
|
84
|
+
}
|
|
85
|
+
if (opts?.allowEmpty && parsed === null)
|
|
86
|
+
return null;
|
|
87
|
+
return parsed;
|
|
67
88
|
}
|
|
68
89
|
catch (err) {
|
|
90
|
+
if (err instanceof NavarchApiError)
|
|
91
|
+
throw err;
|
|
69
92
|
// Include enough request/cause context to diagnose DNS, connection, and
|
|
70
|
-
// TLS failures. Deliberately omit headers and URL
|
|
93
|
+
// TLS/body-stream failures. Deliberately omit headers and URL
|
|
94
|
+
// credentials/query data.
|
|
71
95
|
throw new NavarchTransportError(method, safeEndpoint(requestUrl, pathname), err);
|
|
72
96
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const text = await response.text();
|
|
76
|
-
const parsed = text ? safeJsonParse(text) : null;
|
|
77
|
-
if (!response.ok) {
|
|
78
|
-
throw new NavarchApiError(`Navarch API ${method} ${pathname} failed with ${response.status}`, response.status, parsed ?? text);
|
|
97
|
+
finally {
|
|
98
|
+
clearTimeout(requestTimeout);
|
|
79
99
|
}
|
|
80
|
-
if (opts?.allowEmpty && parsed === null)
|
|
81
|
-
return null;
|
|
82
|
-
return parsed;
|
|
83
100
|
}
|
|
84
101
|
/** Global machine registration using the operator-configured enrollment secret. */
|
|
85
102
|
async registerMachine(req) {
|
|
@@ -163,16 +180,26 @@ function safeEndpoint(requestUrl, pathname) {
|
|
|
163
180
|
return pathname;
|
|
164
181
|
}
|
|
165
182
|
}
|
|
166
|
-
function describeError(err) {
|
|
183
|
+
function describeError(err, seen = new Set(), depth = 0) {
|
|
167
184
|
if (!(err instanceof Error))
|
|
168
185
|
return String(err);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
186
|
+
if (seen.has(err))
|
|
187
|
+
return "[circular error cause]";
|
|
188
|
+
const detail = err.message ? `${err.name}: ${err.message}` : err.name;
|
|
189
|
+
if (depth >= MAX_ERROR_CAUSE_DEPTH)
|
|
190
|
+
return detail;
|
|
191
|
+
seen.add(err);
|
|
192
|
+
const nested = [];
|
|
193
|
+
if (err.cause !== undefined) {
|
|
194
|
+
nested.push(`cause: ${describeError(err.cause, seen, depth + 1)}`);
|
|
195
|
+
}
|
|
196
|
+
if (err instanceof AggregateError) {
|
|
197
|
+
const errors = Array.from(err.errors).slice(0, MAX_AGGREGATE_ERRORS);
|
|
198
|
+
if (errors.length > 0) {
|
|
199
|
+
nested.push(`errors: [${errors
|
|
200
|
+
.map((child) => describeError(child, seen, depth + 1))
|
|
201
|
+
.join("; ")}]`);
|
|
202
|
+
}
|
|
176
203
|
}
|
|
177
|
-
return detail;
|
|
204
|
+
return nested.length > 0 ? `${detail}; ${nested.join("; ")}` : detail;
|
|
178
205
|
}
|
package/dist/cli.cjs
CHANGED
|
@@ -247,8 +247,14 @@ async function startCommand(flags) {
|
|
|
247
247
|
async function superviseCommand(flags) {
|
|
248
248
|
const config = configFromFlags(flags);
|
|
249
249
|
const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
// Resolve and pin the adapter before spawning the worker. The supervisor
|
|
251
|
+
// passes machine credentials through the environment, so the child no
|
|
252
|
+
// longer reads machine.json for identity fields (including agent_type).
|
|
253
|
+
// Without an explicit worker argument, a saved Codex/Gemini selection
|
|
254
|
+
// therefore falls back to Claude Code.
|
|
255
|
+
const agentType = agentFromFlag(flags) ??
|
|
256
|
+
(process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
|
|
257
|
+
const workerArgs = ["--agent", agentType];
|
|
252
258
|
// Pin the identity for this supervisor's lifetime. Without this snapshot, a
|
|
253
259
|
// replacement worker rereads machine.json after an automatic update and can
|
|
254
260
|
// silently become a different machine if another terminal reused the same
|
package/dist/config.cjs
CHANGED
|
@@ -20,6 +20,16 @@ function envInt(env, name, fallback) {
|
|
|
20
20
|
const n = Number(raw);
|
|
21
21
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
22
22
|
}
|
|
23
|
+
/** Default `$CODEX_HOME`, matching the Codex CLI's own resolution. */
|
|
24
|
+
function defaultCodexHome(env) {
|
|
25
|
+
return env.CODEX_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".codex");
|
|
26
|
+
}
|
|
27
|
+
/** Claude Code's global config sits beside the home dir, or under `CLAUDE_CONFIG_DIR`. */
|
|
28
|
+
function defaultClaudeConfigPath(env) {
|
|
29
|
+
return env.CLAUDE_CONFIG_DIR
|
|
30
|
+
? node_path_1.default.join(env.CLAUDE_CONFIG_DIR, ".claude.json")
|
|
31
|
+
: node_path_1.default.join(node_os_1.default.homedir(), ".claude.json");
|
|
32
|
+
}
|
|
23
33
|
function envList(env, name, fallback) {
|
|
24
34
|
const raw = env[name];
|
|
25
35
|
if (!raw)
|
|
@@ -82,5 +92,10 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
82
92
|
updateChannel: env.NAVARCH_UPDATE_CHANNEL === "canary" ? "canary" : "stable",
|
|
83
93
|
autoUpdate: env.NAVARCH_SUPERVISED === "1" &&
|
|
84
94
|
!["off", "false", "0"].includes(env.NAVARCH_AUTO_UPDATE ?? ""),
|
|
95
|
+
// Reads only the quota numbers providers already write to disk, so it is on
|
|
96
|
+
// by default; operators who would rather share nothing can opt out.
|
|
97
|
+
reportUsageLimits: !["off", "false", "0"].includes(env.NAVARCH_USAGE_LIMITS ?? ""),
|
|
98
|
+
codexHome: defaultCodexHome(env),
|
|
99
|
+
claudeConfigPath: defaultClaudeConfigPath(env),
|
|
85
100
|
};
|
|
86
101
|
}
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -175,7 +175,7 @@ function summarize(text, maxLen = 500) {
|
|
|
175
175
|
function mapExitCondition(result) {
|
|
176
176
|
// Agent output is untrusted evidence, including the final completion
|
|
177
177
|
// message. session.cts resolves PR evidence independently through GitHub
|
|
178
|
-
// using
|
|
178
|
+
// using the session repository and the worktree's live branch/commit.
|
|
179
179
|
const parsedJson = parseClaudeJsonResult(result.stdout);
|
|
180
180
|
const evidenceUrls = [];
|
|
181
181
|
if (result.killedByLeaseLoss) {
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -127,6 +127,28 @@ class GitWorktree {
|
|
|
127
127
|
await this.runGit(["-C", this.worktreePath, "remote", "add", "origin", this.cloneUrl], false);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolves the worktree's current branch and commit after an agent turn.
|
|
132
|
+
*
|
|
133
|
+
* Agents may check out an existing PR branch or rename the generated session
|
|
134
|
+
* branch. Completion evidence must follow that live HEAD rather than the
|
|
135
|
+
* immutable branch name allocated when the worktree was created.
|
|
136
|
+
*/
|
|
137
|
+
async resolveCurrentHead() {
|
|
138
|
+
const [branchResult, shaResult] = await Promise.all([
|
|
139
|
+
this.runGit(["-C", this.worktreePath, "rev-parse", "--abbrev-ref", "HEAD"], false),
|
|
140
|
+
this.runGit(["-C", this.worktreePath, "rev-parse", "--verify", "HEAD"], false),
|
|
141
|
+
]);
|
|
142
|
+
const branch = branchResult.stdout.trim();
|
|
143
|
+
const sha = shaResult.stdout.trim();
|
|
144
|
+
if (!/^[0-9a-f]{40,64}$/i.test(sha)) {
|
|
145
|
+
throw new Error(`git rev-parse returned an invalid HEAD SHA: ${sha || "<empty>"}`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
branch: branch && branch !== "HEAD" ? branch : null,
|
|
149
|
+
sha,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
130
152
|
async cleanup() {
|
|
131
153
|
await withRepositoryLock(this.repositoryPath, async () => {
|
|
132
154
|
await this.runner
|
package/dist/github-pr.cjs
CHANGED
|
@@ -22,21 +22,49 @@ async function resolveGitHubToken(options) {
|
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
function matchingPullRequestUrl(body, repository, matchesHead) {
|
|
26
|
+
if (!Array.isArray(body)) {
|
|
27
|
+
throw new Error("GitHub pull request lookup returned a non-array response");
|
|
28
|
+
}
|
|
29
|
+
const matches = body.filter((candidate) => {
|
|
30
|
+
if (!matchesHead(candidate))
|
|
31
|
+
return false;
|
|
32
|
+
const headRepository = candidate.head?.repo?.full_name;
|
|
33
|
+
if (typeof headRepository !== "string")
|
|
34
|
+
return false;
|
|
35
|
+
if (headRepository.toLowerCase() !== repository)
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof candidate.html_url !== "string")
|
|
38
|
+
return false;
|
|
39
|
+
return isPullRequestUrlForRepository(candidate.html_url, repository);
|
|
40
|
+
});
|
|
41
|
+
const candidate = matches.find((pullRequest) => pullRequest.state === "open") ?? matches[0];
|
|
42
|
+
return typeof candidate?.html_url === "string" ? candidate.html_url : null;
|
|
43
|
+
}
|
|
44
|
+
async function fetchPullRequests(url, headers, fetchImpl) {
|
|
45
|
+
const response = await fetchImpl(url, { headers });
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
return response.json();
|
|
50
|
+
}
|
|
25
51
|
/**
|
|
26
|
-
* Finds
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
52
|
+
* Finds a same-repository PR for the worktree's current head.
|
|
53
|
+
*
|
|
54
|
+
* The exact branch remains the first and cheapest lookup. If an agent checked
|
|
55
|
+
* out or renamed a branch, the current commit provides a secure fallback:
|
|
56
|
+
* GitHub must report a PR whose head SHA and head repository exactly match the
|
|
57
|
+
* local worktree before its URL is accepted as completion evidence.
|
|
30
58
|
*/
|
|
31
59
|
async function findHeadBranchPullRequestUrl(options) {
|
|
32
60
|
const [owner, name, ...extra] = options.repository.split("/");
|
|
33
61
|
if (!owner || !name || extra.length > 0) {
|
|
34
62
|
throw new Error(`Invalid GitHub repository name: ${options.repository}`);
|
|
35
63
|
}
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
const branchUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
|
|
65
|
+
branchUrl.searchParams.set("state", "all");
|
|
66
|
+
branchUrl.searchParams.set("head", `${owner}:${options.headBranch}`);
|
|
67
|
+
branchUrl.searchParams.set("per_page", "10");
|
|
40
68
|
const headers = {
|
|
41
69
|
accept: "application/vnd.github+json",
|
|
42
70
|
"user-agent": "navarch-runtime",
|
|
@@ -45,30 +73,22 @@ async function findHeadBranchPullRequestUrl(options) {
|
|
|
45
73
|
const githubToken = await resolveGitHubToken(options);
|
|
46
74
|
if (githubToken)
|
|
47
75
|
headers.authorization = `Bearer ${githubToken}`;
|
|
48
|
-
const
|
|
49
|
-
if (!response.ok) {
|
|
50
|
-
throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}`);
|
|
51
|
-
}
|
|
52
|
-
const body = await response.json();
|
|
53
|
-
if (!Array.isArray(body)) {
|
|
54
|
-
throw new Error("GitHub pull request lookup returned a non-array response");
|
|
55
|
-
}
|
|
76
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
56
77
|
const normalizedRepository = options.repository.toLowerCase();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const headRepository = candidate.head.repo?.full_name;
|
|
61
|
-
if (typeof headRepository !== "string")
|
|
62
|
-
continue;
|
|
63
|
-
if (headRepository.toLowerCase() !== normalizedRepository)
|
|
64
|
-
continue;
|
|
65
|
-
if (typeof candidate.html_url !== "string")
|
|
66
|
-
continue;
|
|
67
|
-
if (!isPullRequestUrlForRepository(candidate.html_url, normalizedRepository))
|
|
68
|
-
continue;
|
|
69
|
-
return candidate.html_url;
|
|
78
|
+
const normalizedHeadSha = options.headSha?.toLowerCase();
|
|
79
|
+
if (normalizedHeadSha && !/^[0-9a-f]{40,64}$/.test(normalizedHeadSha)) {
|
|
80
|
+
throw new Error(`Invalid Git head SHA: ${options.headSha}`);
|
|
70
81
|
}
|
|
71
|
-
|
|
82
|
+
const branchMatch = matchingPullRequestUrl(await fetchPullRequests(branchUrl, headers, fetchImpl), normalizedRepository, (candidate) => candidate.head?.ref === options.headBranch &&
|
|
83
|
+
(!normalizedHeadSha ||
|
|
84
|
+
(typeof candidate.head.sha === "string" &&
|
|
85
|
+
candidate.head.sha.toLowerCase() === normalizedHeadSha)));
|
|
86
|
+
if (branchMatch || !normalizedHeadSha)
|
|
87
|
+
return branchMatch;
|
|
88
|
+
const commitUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${normalizedHeadSha}/pulls`);
|
|
89
|
+
commitUrl.searchParams.set("per_page", "10");
|
|
90
|
+
return matchingPullRequestUrl(await fetchPullRequests(commitUrl, headers, fetchImpl), normalizedRepository, (candidate) => typeof candidate.head?.sha === "string" &&
|
|
91
|
+
candidate.head.sha.toLowerCase() === normalizedHeadSha);
|
|
72
92
|
}
|
|
73
93
|
function isPullRequestUrlForRepository(value, repository) {
|
|
74
94
|
try {
|
package/dist/heartbeat-loop.cjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MachineHeartbeatLoop = void 0;
|
|
4
4
|
const version_cjs_1 = require("./version.cjs");
|
|
5
|
+
const usage_limits_cjs_1 = require("./usage-limits.cjs");
|
|
5
6
|
const logger_cjs_1 = require("./logger.cjs");
|
|
6
7
|
const log = (0, logger_cjs_1.createLogger)("heartbeat");
|
|
7
8
|
/**
|
|
@@ -19,6 +20,8 @@ class MachineHeartbeatLoop {
|
|
|
19
20
|
onResult;
|
|
20
21
|
onHealthy;
|
|
21
22
|
timer = null;
|
|
23
|
+
heartbeatInFlight = false;
|
|
24
|
+
heartbeatPending = false;
|
|
22
25
|
updateState = "idle";
|
|
23
26
|
lastUpdateError;
|
|
24
27
|
draining = false;
|
|
@@ -41,6 +44,7 @@ class MachineHeartbeatLoop {
|
|
|
41
44
|
if (this.timer)
|
|
42
45
|
clearInterval(this.timer);
|
|
43
46
|
this.timer = null;
|
|
47
|
+
this.heartbeatPending = false;
|
|
44
48
|
}
|
|
45
49
|
setUpdateState(state, error) {
|
|
46
50
|
this.updateState = state;
|
|
@@ -49,24 +53,48 @@ class MachineHeartbeatLoop {
|
|
|
49
53
|
void this.tick();
|
|
50
54
|
}
|
|
51
55
|
async tick() {
|
|
56
|
+
if (this.heartbeatInFlight) {
|
|
57
|
+
this.heartbeatPending = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.heartbeatInFlight = true;
|
|
52
61
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
do {
|
|
63
|
+
this.heartbeatPending = false;
|
|
64
|
+
try {
|
|
65
|
+
// Always sent when collection is enabled, including as an empty array:
|
|
66
|
+
// that is how the control plane learns a previously reported quota is
|
|
67
|
+
// gone (agent uninstalled, telemetry disabled) instead of pinning a
|
|
68
|
+
// reading that no longer exists.
|
|
69
|
+
const agentUsage = this.config.reportUsageLimits
|
|
70
|
+
? await (0, usage_limits_cjs_1.collectAgentUsage)({
|
|
71
|
+
codexHome: this.config.codexHome,
|
|
72
|
+
claudeConfigPath: this.config.claudeConfigPath,
|
|
73
|
+
})
|
|
74
|
+
: null;
|
|
75
|
+
const result = await this.api.machineHeartbeat(this.machineId, {
|
|
76
|
+
available_capacity: this.draining ? 0 : this.capacity.available(),
|
|
77
|
+
capabilities: this.config.capabilities,
|
|
78
|
+
...(agentUsage ? { agent_usage: agentUsage } : {}),
|
|
79
|
+
runtime: {
|
|
80
|
+
version: version_cjs_1.RUNTIME_VERSION,
|
|
81
|
+
updater_protocol: version_cjs_1.UPDATER_PROTOCOL_VERSION,
|
|
82
|
+
channel: this.config.updateChannel,
|
|
83
|
+
state: this.updateState,
|
|
84
|
+
boot_id: this.bootId,
|
|
85
|
+
...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
this.onHealthy?.();
|
|
89
|
+
this.onResult?.(result);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
log.warn(`machine heartbeat failed: ${String(err)}`);
|
|
93
|
+
}
|
|
94
|
+
} while (this.heartbeatPending);
|
|
67
95
|
}
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
finally {
|
|
97
|
+
this.heartbeatInFlight = false;
|
|
70
98
|
}
|
|
71
99
|
}
|
|
72
100
|
}
|
package/dist/session.cjs
CHANGED
|
@@ -259,14 +259,17 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
// Kept outside the try so the crash path can report the telemetry this
|
|
263
|
+
// session actually produced instead of zeroing it.
|
|
264
|
+
let lastCompletion = null;
|
|
262
265
|
try {
|
|
263
266
|
await gitWorktree.prepare();
|
|
264
267
|
if (sandbox) {
|
|
265
268
|
await sandbox.create();
|
|
266
269
|
await sandbox.injectEnv(toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail));
|
|
267
270
|
}
|
|
268
|
-
// The control plane
|
|
269
|
-
//
|
|
271
|
+
// The control plane returns the agent type selected for this worker.
|
|
272
|
+
// Any agent type may run any task; capabilities remain the task-level gate.
|
|
270
273
|
// All adapters implement the same AgentAdapter.run() shape
|
|
271
274
|
// (adapters/types.cts), so nothing else in this function branches on
|
|
272
275
|
// which agent is running.
|
|
@@ -334,10 +337,22 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
334
337
|
...result,
|
|
335
338
|
killedByLeaseLoss: leaseLost || result.killedByLeaseLoss,
|
|
336
339
|
});
|
|
340
|
+
let headBranch = gitWorktree.branch;
|
|
341
|
+
let headSha;
|
|
342
|
+
try {
|
|
343
|
+
const currentHead = await gitWorktree.resolveCurrentHead();
|
|
344
|
+
headBranch = currentHead.branch ?? headBranch;
|
|
345
|
+
headSha = currentHead.sha;
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
// Preserve the original exact-branch lookup if HEAD cannot be read.
|
|
349
|
+
log.warn(`worktree HEAD lookup failed for ${leaseId}: ${String(err)}`);
|
|
350
|
+
}
|
|
337
351
|
try {
|
|
338
352
|
const prUrl = await (0, github_pr_cjs_1.findHeadBranchPullRequestUrl)({
|
|
339
353
|
repository: bundle.repository?.full_name ?? task.repo,
|
|
340
|
-
headBranch
|
|
354
|
+
headBranch,
|
|
355
|
+
headSha,
|
|
341
356
|
githubToken,
|
|
342
357
|
});
|
|
343
358
|
if (prUrl)
|
|
@@ -369,7 +384,9 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
369
384
|
transcriptUrl = public_url;
|
|
370
385
|
}
|
|
371
386
|
catch (err) {
|
|
372
|
-
|
|
387
|
+
// The message carries up to 500 chars of the storage response body, so
|
|
388
|
+
// redact it like every other warn in this block.
|
|
389
|
+
log.warn(`transcript upload failed for ${leaseId}: ${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}`);
|
|
373
390
|
}
|
|
374
391
|
const completion = {
|
|
375
392
|
status: mapping.leaseOutcome,
|
|
@@ -385,6 +402,12 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
385
402
|
agent_type: runtime,
|
|
386
403
|
...executionReport,
|
|
387
404
|
};
|
|
405
|
+
lastCompletion = {
|
|
406
|
+
report: completion.report,
|
|
407
|
+
evidence_urls: completion.evidence_urls,
|
|
408
|
+
cost: completion.cost,
|
|
409
|
+
transcript_url: completion.transcript_url,
|
|
410
|
+
};
|
|
388
411
|
try {
|
|
389
412
|
await api.completeLease(leaseId, completion);
|
|
390
413
|
break;
|
|
@@ -394,38 +417,70 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
394
417
|
log.warn(`completion skipped for ${leaseId}: the lease was already released.`);
|
|
395
418
|
return sessionOutcome;
|
|
396
419
|
}
|
|
397
|
-
const rejection =
|
|
420
|
+
const rejection = completionRejection(err);
|
|
398
421
|
if (!rejection)
|
|
399
422
|
throw err;
|
|
400
|
-
const redactedRejection = (0, redact_cjs_1.redactText)(rejection, knownSecrets);
|
|
401
|
-
|
|
423
|
+
const redactedRejection = (0, redact_cjs_1.redactText)(rejection.message, knownSecrets);
|
|
424
|
+
const remediable = mapping.leaseOutcome === "completed" && rejection.remediable;
|
|
425
|
+
if (remediable && completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
|
|
402
426
|
completionRemediationRetries += 1;
|
|
403
427
|
log.warn(`completion for ${leaseId} needs more work; restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
|
|
404
428
|
nextPrompt = redactedRejection;
|
|
405
429
|
continue;
|
|
406
430
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
431
|
+
if (remediable) {
|
|
432
|
+
log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries; failing with the control-plane rejection.`);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
// Not something another turn can fix (e.g. review_evidence_tampered).
|
|
436
|
+
// Fail the lease once, keeping this attempt's real report, cost and
|
|
437
|
+
// evidence — this is a rejected attempt, not a crashed session.
|
|
438
|
+
log.error(`completion for ${leaseId} was rejected as ${rejection.code}, which is not remediable; failing the lease.`);
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
await api.completeLease(leaseId, {
|
|
442
|
+
...completion,
|
|
443
|
+
status: "failed",
|
|
444
|
+
report: remediable
|
|
445
|
+
? redactedRejection
|
|
446
|
+
: `${redactedRejection}\n\n---\n\n${completion.report}`,
|
|
447
|
+
failure_summary: redactedRejection,
|
|
448
|
+
exit_status: "failed",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
catch (fallbackErr) {
|
|
452
|
+
// Same race as the primary completion above: if something else released
|
|
453
|
+
// the lease first, this is a clean exit, not a crash. Letting it escape
|
|
454
|
+
// would post a second `Session crashed:` completion with zeroed cost.
|
|
455
|
+
if (!isAlreadyReleasedCompletionError(fallbackErr))
|
|
456
|
+
throw fallbackErr;
|
|
457
|
+
log.warn(`fallback completion skipped for ${leaseId}: the lease was already released.`);
|
|
458
|
+
return sessionOutcome;
|
|
459
|
+
}
|
|
415
460
|
break;
|
|
416
461
|
}
|
|
417
462
|
}
|
|
418
463
|
}
|
|
419
464
|
catch (err) {
|
|
420
|
-
|
|
421
|
-
|
|
465
|
+
const knownSecrets = registry.list();
|
|
466
|
+
// NavarchApiError's message is only the status line; the control plane's
|
|
467
|
+
// explanation rides in `err.body`. Keep it in the agent log (redacted like
|
|
468
|
+
// every other body we surface) so a 4xx/5xx completion stays diagnosable.
|
|
469
|
+
const crashDetail = (0, redact_cjs_1.redactText)(describeCompletionError(err), knownSecrets);
|
|
470
|
+
log.error(`session ${leaseId} threw before completing: ${crashDetail}`);
|
|
471
|
+
const failureSummary = (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, knownSecrets);
|
|
422
472
|
await api
|
|
423
473
|
.completeLease(leaseId, {
|
|
424
474
|
status: "failed",
|
|
425
|
-
report:
|
|
475
|
+
report: lastCompletion
|
|
476
|
+
? `${lastCompletion.report}\n\n---\n\n${failureSummary}`
|
|
477
|
+
: failureSummary,
|
|
426
478
|
failure_summary: failureSummary,
|
|
427
|
-
evidence_urls: [],
|
|
428
|
-
cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
|
|
479
|
+
evidence_urls: lastCompletion?.evidence_urls ?? [],
|
|
480
|
+
cost: lastCompletion?.cost ?? { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
|
|
481
|
+
...(lastCompletion?.transcript_url
|
|
482
|
+
? { transcript_url: lastCompletion.transcript_url }
|
|
483
|
+
: {}),
|
|
429
484
|
exit_status: "crashed",
|
|
430
485
|
agent_type: runtime,
|
|
431
486
|
...executionReport,
|
|
@@ -460,27 +515,100 @@ function sumReportedUsage(attempts, key) {
|
|
|
460
515
|
});
|
|
461
516
|
return reported.length > 0 ? reported.reduce((sum, value) => sum + value, 0) : undefined;
|
|
462
517
|
}
|
|
463
|
-
|
|
518
|
+
/** Rejection codes another agent turn in the same worktree can plausibly fix. */
|
|
519
|
+
const REMEDIABLE_REJECTION_CODES = new Set([
|
|
520
|
+
"pr_required",
|
|
521
|
+
"pr_not_ready",
|
|
522
|
+
"review_attestation_required",
|
|
523
|
+
"review_attestation_invalid",
|
|
524
|
+
"review_head_changed",
|
|
525
|
+
"review_evidence_missing",
|
|
526
|
+
// The marker is already on the right head; the reviewer only has to relabel
|
|
527
|
+
// the body, which is exactly what a remediation turn can do.
|
|
528
|
+
"review_evidence_mislabeled",
|
|
529
|
+
]);
|
|
530
|
+
/**
|
|
531
|
+
* A 409 the control plane raised to reject *this* completion's contents (as
|
|
532
|
+
* opposed to a lease that is simply gone). Non-remediable codes still describe
|
|
533
|
+
* a failed attempt, not a crashed session, so the caller fails the lease once
|
|
534
|
+
* rather than routing the error through the crash path.
|
|
535
|
+
*/
|
|
536
|
+
function completionRejection(err) {
|
|
464
537
|
if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
|
|
465
538
|
return null;
|
|
466
|
-
if (typeof err.body
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
539
|
+
if (typeof err.body === "object" && err.body !== null && !Array.isArray(err.body)) {
|
|
540
|
+
const body = err.body;
|
|
541
|
+
if (typeof body.code === "string" && typeof body.error === "string") {
|
|
542
|
+
return {
|
|
543
|
+
code: body.code,
|
|
544
|
+
message: body.error,
|
|
545
|
+
remediable: REMEDIABLE_REJECTION_CODES.has(body.code),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// A 409 whose body the runtime cannot interpret — an edge/proxy error page,
|
|
550
|
+
// an HTML body, a truncated response (api.cts hands the raw text through when
|
|
551
|
+
// safeJsonParse fails). The lease is still rejected, so fail it once with this
|
|
552
|
+
// attempt's real report/cost/evidence rather than crashing the session and
|
|
553
|
+
// posting a zeroed-cost `Session crashed:` completion. Callers check
|
|
554
|
+
// isAlreadyReleasedCompletionError first, so the benign race never lands here.
|
|
555
|
+
return {
|
|
556
|
+
code: UNRECOGNIZED_REJECTION_CODE,
|
|
557
|
+
message: "The control plane rejected this completion with HTTP 409, but the response body " +
|
|
558
|
+
`was not a recognizable rejection: ${describeResponseBody(err.body)}`,
|
|
559
|
+
remediable: false,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/** Synthetic code for a 409 that carried no machine-readable `{error, code}` pair. */
|
|
563
|
+
const UNRECOGNIZED_REJECTION_CODE = "unrecognized_completion_rejection";
|
|
564
|
+
const MAX_DESCRIBED_BODY_CHARS = 500;
|
|
565
|
+
/** Collapses an arbitrary error-response body into one bounded, loggable line. */
|
|
566
|
+
function describeResponseBody(body) {
|
|
567
|
+
let text;
|
|
568
|
+
if (body === null || body === undefined) {
|
|
569
|
+
text = "";
|
|
570
|
+
}
|
|
571
|
+
else if (typeof body === "string") {
|
|
572
|
+
text = body;
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
try {
|
|
576
|
+
text = JSON.stringify(body) ?? String(body);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
text = String(body);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
583
|
+
if (collapsed.length === 0)
|
|
584
|
+
return "<empty>";
|
|
585
|
+
return collapsed.length > MAX_DESCRIBED_BODY_CHARS
|
|
586
|
+
? `${collapsed.slice(0, MAX_DESCRIBED_BODY_CHARS)}…`
|
|
587
|
+
: collapsed;
|
|
588
|
+
}
|
|
589
|
+
/** Error text for the crash log, including a NavarchApiError's response body. */
|
|
590
|
+
function describeCompletionError(err) {
|
|
591
|
+
if (!(err instanceof api_cjs_1.NavarchApiError))
|
|
592
|
+
return String(err);
|
|
593
|
+
return `${String(err)} (body: ${describeResponseBody(err.body)})`;
|
|
473
594
|
}
|
|
474
595
|
function isTerminalLeaseHeartbeatError(err) {
|
|
475
596
|
return err instanceof api_cjs_1.NavarchApiError && [403, 404, 410].includes(err.status);
|
|
476
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* A 409 that means "someone already released this lease" — a benign race, not
|
|
600
|
+
* work to remediate. The control plane tags it `code: "already_released"`
|
|
601
|
+
* (app/api/dispatch/[leaseId]/complete/route.ts); the prose form is what
|
|
602
|
+
* control planes before that shipped, and is kept so a new runtime still
|
|
603
|
+
* recognizes an older deployment.
|
|
604
|
+
*/
|
|
477
605
|
function isAlreadyReleasedCompletionError(err) {
|
|
478
606
|
if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
|
|
479
607
|
return false;
|
|
480
608
|
if (typeof err.body !== "object" || err.body === null || Array.isArray(err.body))
|
|
481
609
|
return false;
|
|
482
610
|
const body = err.body;
|
|
483
|
-
return body.error === "lease already released";
|
|
611
|
+
return body.code === "already_released" || body.error === "lease already released";
|
|
484
612
|
}
|
|
485
613
|
/** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
|
|
486
614
|
function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
|
package/dist/upload.cjs
CHANGED
|
@@ -7,6 +7,8 @@ exports.uploadTranscript = uploadTranscript;
|
|
|
7
7
|
* implementation-plan.md WP-07). Kept as its own module so the storage
|
|
8
8
|
* mechanism is a one-function swap if it ever changes.
|
|
9
9
|
*/
|
|
10
|
+
/** Storage error bodies are small JSON blobs; cap in case we get an HTML page. */
|
|
11
|
+
const MAX_ERROR_BODY = 500;
|
|
10
12
|
async function uploadTranscript(uploadUrl, content, fetchImpl = fetch) {
|
|
11
13
|
const response = await fetchImpl(uploadUrl, {
|
|
12
14
|
method: "PUT",
|
|
@@ -14,6 +16,21 @@ async function uploadTranscript(uploadUrl, content, fetchImpl = fetch) {
|
|
|
14
16
|
body: content,
|
|
15
17
|
});
|
|
16
18
|
if (!response.ok) {
|
|
17
|
-
|
|
19
|
+
// Supabase Storage answers with {statusCode, error, message} here, which is
|
|
20
|
+
// the only thing distinguishing an RLS rejection from a bad/expired token
|
|
21
|
+
// from a bucket misconfiguration. Without it a 403 is unattributable.
|
|
22
|
+
throw new Error(`Transcript upload failed with ${response.status}: ${await readErrorBody(response)}`);
|
|
18
23
|
}
|
|
19
24
|
}
|
|
25
|
+
async function readErrorBody(response) {
|
|
26
|
+
let body;
|
|
27
|
+
try {
|
|
28
|
+
body = (await response.text()).trim();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return `<unreadable response body: ${String(err)}>`;
|
|
32
|
+
}
|
|
33
|
+
if (!body)
|
|
34
|
+
return "<empty response body>";
|
|
35
|
+
return body.length > MAX_ERROR_BODY ? `${body.slice(0, MAX_ERROR_BODY)}…` : body;
|
|
36
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.classifyWindow = classifyWindow;
|
|
7
|
+
exports.parseCodexRateLimitSnapshot = parseCodexRateLimitSnapshot;
|
|
8
|
+
exports.findLatestCodexSnapshot = findLatestCodexSnapshot;
|
|
9
|
+
exports.collectCodexUsage = collectCodexUsage;
|
|
10
|
+
exports.parseClaudeUsageCache = parseClaudeUsageCache;
|
|
11
|
+
exports.collectClaudeUsage = collectClaudeUsage;
|
|
12
|
+
exports.collectAgentUsage = collectAgentUsage;
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const logger_cjs_1 = require("./logger.cjs");
|
|
16
|
+
const log = (0, logger_cjs_1.createLogger)("usage-limits");
|
|
17
|
+
/**
|
|
18
|
+
* Reads the usage-limit telemetry that coding agents already write to disk on
|
|
19
|
+
* this machine and normalizes it for the machine heartbeat (see
|
|
20
|
+
* heartbeat-loop.cts).
|
|
21
|
+
*
|
|
22
|
+
* Ground rules, because this reads files an agent wrote for its own use:
|
|
23
|
+
*
|
|
24
|
+
* - Only provider-emitted quota numbers are collected. Credentials, prompts,
|
|
25
|
+
* transcripts and token counts that sit next to them in the same files are
|
|
26
|
+
* never read out, and the report is rebuilt field by field rather than
|
|
27
|
+
* forwarded, so nothing unrecognized can ride along.
|
|
28
|
+
* - Every field is optional and version-sensitive. A value we cannot verify is
|
|
29
|
+
* dropped, never guessed: a missing window is reported as absent so the UI
|
|
30
|
+
* can say "not reported" instead of inventing a quota.
|
|
31
|
+
* - Collection is best-effort and must never break a heartbeat. Every failure
|
|
32
|
+
* path returns an empty report.
|
|
33
|
+
*/
|
|
34
|
+
/** Cap on windows kept per provider — Codex reports 2, Claude Code 2. */
|
|
35
|
+
const MAX_WINDOWS_PER_PROVIDER = 6;
|
|
36
|
+
/** Tail of a rollout file we are willing to read; snapshots are appended. */
|
|
37
|
+
const ROLLOUT_TAIL_BYTES = 256 * 1024;
|
|
38
|
+
/** Newest rollout files to scan before giving up on finding a snapshot. */
|
|
39
|
+
const MAX_ROLLOUT_CANDIDATES = 5;
|
|
40
|
+
/** Ceiling on rollout paths collected while walking the sessions tree. */
|
|
41
|
+
const MAX_ROLLOUT_SCAN = 200;
|
|
42
|
+
/** Largest `.claude.json` we will parse. The file also holds unrelated state. */
|
|
43
|
+
const MAX_CLAUDE_CONFIG_BYTES = 8 * 1024 * 1024;
|
|
44
|
+
/**
|
|
45
|
+
* Window durations Codex's own TUI recognizes, with the same ±5% tolerance it
|
|
46
|
+
* uses (codex-rs/tui/src/chatwidget/rate_limits.rs). Neither provider promises
|
|
47
|
+
* that `primary` is the short window, so the label is always derived from the
|
|
48
|
+
* reported duration rather than the slot it arrived in.
|
|
49
|
+
*/
|
|
50
|
+
const WINDOW_KINDS = [
|
|
51
|
+
[300, "5h"],
|
|
52
|
+
[1440, "daily"],
|
|
53
|
+
[10080, "weekly"],
|
|
54
|
+
[43200, "monthly"],
|
|
55
|
+
[525600, "annual"],
|
|
56
|
+
];
|
|
57
|
+
function classifyWindow(windowMinutes) {
|
|
58
|
+
if (windowMinutes === null)
|
|
59
|
+
return "unknown";
|
|
60
|
+
for (const [expected, kind] of WINDOW_KINDS) {
|
|
61
|
+
if (windowMinutes >= expected * 0.95 && windowMinutes <= expected * 1.05)
|
|
62
|
+
return kind;
|
|
63
|
+
}
|
|
64
|
+
return "unknown";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Percentages are contract-bounded to 0-100. An out-of-range number means we
|
|
68
|
+
* misread the provider's scale, so the window is dropped rather than clamped —
|
|
69
|
+
* clamping 4200 to 100 would render as a confident "quota exhausted".
|
|
70
|
+
*/
|
|
71
|
+
function normalizeUsedPercent(value) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
73
|
+
return null;
|
|
74
|
+
if (value < 0 || value > 100)
|
|
75
|
+
return null;
|
|
76
|
+
return Math.round(value * 10) / 10;
|
|
77
|
+
}
|
|
78
|
+
/** Positive whole minutes, capped at ten years to reject nonsense durations. */
|
|
79
|
+
function normalizeWindowMinutes(value) {
|
|
80
|
+
if (typeof value !== "number" || !Number.isInteger(value))
|
|
81
|
+
return null;
|
|
82
|
+
if (value <= 0 || value > 5_256_000)
|
|
83
|
+
return null;
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
/** Rejects reset instants outside [2020, now + 2y] as unparseable rather than real. */
|
|
87
|
+
function isoFromEpochMs(ms, nowMs) {
|
|
88
|
+
if (!Number.isFinite(ms))
|
|
89
|
+
return null;
|
|
90
|
+
if (ms < Date.UTC(2020, 0, 1))
|
|
91
|
+
return null;
|
|
92
|
+
if (ms > nowMs + 2 * 365 * 24 * 60 * 60 * 1000)
|
|
93
|
+
return null;
|
|
94
|
+
return new Date(ms).toISOString();
|
|
95
|
+
}
|
|
96
|
+
function isoFromEpochSeconds(value, nowMs) {
|
|
97
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
98
|
+
return null;
|
|
99
|
+
return isoFromEpochMs(Math.round(value * 1000), nowMs);
|
|
100
|
+
}
|
|
101
|
+
/** Codex ≤ v0.47 reported a duration relative to the emitting event. */
|
|
102
|
+
function isoFromRelativeSeconds(value, observedAtMs, nowMs) {
|
|
103
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
104
|
+
return null;
|
|
105
|
+
return isoFromEpochMs(observedAtMs + Math.round(value * 1000), nowMs);
|
|
106
|
+
}
|
|
107
|
+
function isoFromString(value, nowMs) {
|
|
108
|
+
if (typeof value !== "string" || !value.trim())
|
|
109
|
+
return null;
|
|
110
|
+
const parsed = Date.parse(value);
|
|
111
|
+
return Number.isNaN(parsed) ? null : isoFromEpochMs(parsed, nowMs);
|
|
112
|
+
}
|
|
113
|
+
function buildWindow(usedPercent, windowMinutes, resetsAt) {
|
|
114
|
+
if (usedPercent === null)
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
kind: classifyWindow(windowMinutes),
|
|
118
|
+
window_minutes: windowMinutes,
|
|
119
|
+
used_percent: usedPercent,
|
|
120
|
+
resets_at: resetsAt,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// --------------------------------------------------------------------------
|
|
124
|
+
// Codex — session rollout JSONL
|
|
125
|
+
// --------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Parses one Codex `RateLimitSnapshot`. Field names are stable across the
|
|
128
|
+
* versions we support (`used_percent`, `window_minutes`); `resets_at` (epoch
|
|
129
|
+
* seconds) replaced `resets_in_seconds` in Codex v0.48, so both are accepted.
|
|
130
|
+
* Everything else in the snapshot — plan type, credit balance, spend controls —
|
|
131
|
+
* is deliberately ignored.
|
|
132
|
+
*/
|
|
133
|
+
function parseCodexRateLimitSnapshot(snapshot, observedAtMs, nowMs) {
|
|
134
|
+
if (!snapshot || typeof snapshot !== "object")
|
|
135
|
+
return [];
|
|
136
|
+
const raw = snapshot;
|
|
137
|
+
const windows = [];
|
|
138
|
+
for (const slot of ["primary", "secondary"]) {
|
|
139
|
+
const value = raw[slot];
|
|
140
|
+
if (!value || typeof value !== "object")
|
|
141
|
+
continue;
|
|
142
|
+
const win = value;
|
|
143
|
+
const parsed = buildWindow(normalizeUsedPercent(win.used_percent), normalizeWindowMinutes(win.window_minutes), isoFromEpochSeconds(win.resets_at, nowMs) ??
|
|
144
|
+
isoFromRelativeSeconds(win.resets_in_seconds, observedAtMs, nowMs));
|
|
145
|
+
if (parsed)
|
|
146
|
+
windows.push(parsed);
|
|
147
|
+
}
|
|
148
|
+
return windows.slice(0, MAX_WINDOWS_PER_PROVIDER);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Scans a rollout JSONL tail backwards for the newest `token_count` event that
|
|
152
|
+
* carries a rate-limit snapshot. Codex appends these throughout a session, so
|
|
153
|
+
* the last one is the freshest quota reading this machine has seen.
|
|
154
|
+
*/
|
|
155
|
+
function findLatestCodexSnapshot(jsonl) {
|
|
156
|
+
const lines = jsonl.split("\n");
|
|
157
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
158
|
+
const line = lines[i]?.trim() ?? "";
|
|
159
|
+
// Cheap reject before paying for JSON.parse on every transcript line.
|
|
160
|
+
if (!line || !line.includes("\"token_count\"") || !line.includes("\"rate_limits\""))
|
|
161
|
+
continue;
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(line);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!parsed || typeof parsed !== "object")
|
|
170
|
+
continue;
|
|
171
|
+
const entry = parsed;
|
|
172
|
+
if (entry.type !== "event_msg")
|
|
173
|
+
continue;
|
|
174
|
+
const payload = entry.payload;
|
|
175
|
+
if (!payload || typeof payload !== "object")
|
|
176
|
+
continue;
|
|
177
|
+
const event = payload;
|
|
178
|
+
if (event.type !== "token_count")
|
|
179
|
+
continue;
|
|
180
|
+
if (!event.rate_limits || typeof event.rate_limits !== "object")
|
|
181
|
+
continue;
|
|
182
|
+
const timestamp = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
|
|
183
|
+
return {
|
|
184
|
+
snapshot: event.rate_limits,
|
|
185
|
+
observedAtMs: Number.isNaN(timestamp) ? null : timestamp,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
async function listSortedDirs(dir, limit) {
|
|
191
|
+
const entries = await node_fs_1.promises.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
192
|
+
return entries
|
|
193
|
+
.filter((entry) => entry.isDirectory())
|
|
194
|
+
.map((entry) => entry.name)
|
|
195
|
+
.sort()
|
|
196
|
+
.reverse()
|
|
197
|
+
.slice(0, limit);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Collects rollout paths from the newest `sessions/YYYY/MM/DD` directories.
|
|
201
|
+
* Names are zero-padded, so lexical order is chronological order; two branches
|
|
202
|
+
* per level covers a month or year boundary crossed mid-session.
|
|
203
|
+
*/
|
|
204
|
+
async function recentRolloutFiles(sessionsDir) {
|
|
205
|
+
const files = [];
|
|
206
|
+
for (const year of await listSortedDirs(sessionsDir, 2)) {
|
|
207
|
+
for (const month of await listSortedDirs(node_path_1.default.join(sessionsDir, year), 2)) {
|
|
208
|
+
for (const day of await listSortedDirs(node_path_1.default.join(sessionsDir, year, month), 2)) {
|
|
209
|
+
const dayDir = node_path_1.default.join(sessionsDir, year, month, day);
|
|
210
|
+
const entries = await node_fs_1.promises.readdir(dayDir, { withFileTypes: true }).catch(() => []);
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
// Cold rollouts are rewritten as `.jsonl.zst`; the newest session —
|
|
213
|
+
// the only one with a current quota reading — is never compressed.
|
|
214
|
+
if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
|
|
215
|
+
files.push(node_path_1.default.join(dayDir, entry.name));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (files.length >= MAX_ROLLOUT_SCAN)
|
|
219
|
+
return files;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return files;
|
|
224
|
+
}
|
|
225
|
+
/** Reads the last `maxBytes` of a file, discarding a leading partial line. */
|
|
226
|
+
async function readTail(file, maxBytes) {
|
|
227
|
+
const handle = await node_fs_1.promises.open(file, "r");
|
|
228
|
+
try {
|
|
229
|
+
const { size } = await handle.stat();
|
|
230
|
+
const start = size > maxBytes ? size - maxBytes : 0;
|
|
231
|
+
const length = size - start;
|
|
232
|
+
if (length <= 0)
|
|
233
|
+
return "";
|
|
234
|
+
const buffer = Buffer.alloc(length);
|
|
235
|
+
await handle.read(buffer, 0, length, start);
|
|
236
|
+
const text = buffer.toString("utf8");
|
|
237
|
+
if (start === 0)
|
|
238
|
+
return text;
|
|
239
|
+
const firstBreak = text.indexOf("\n");
|
|
240
|
+
return firstBreak === -1 ? "" : text.slice(firstBreak + 1);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await handle.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function collectCodexUsage(options) {
|
|
247
|
+
const nowMs = (options.now ?? Date.now)();
|
|
248
|
+
const sessionsDir = node_path_1.default.join(options.codexHome, "sessions");
|
|
249
|
+
const candidates = await recentRolloutFiles(sessionsDir);
|
|
250
|
+
if (candidates.length === 0)
|
|
251
|
+
return null;
|
|
252
|
+
const stated = await Promise.all(candidates.map(async (file) => {
|
|
253
|
+
const stat = await node_fs_1.promises.stat(file).catch(() => null);
|
|
254
|
+
return stat ? { file, mtimeMs: stat.mtimeMs } : null;
|
|
255
|
+
}));
|
|
256
|
+
const newest = stated
|
|
257
|
+
.filter((entry) => entry !== null)
|
|
258
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
259
|
+
.slice(0, MAX_ROLLOUT_CANDIDATES);
|
|
260
|
+
for (const { file, mtimeMs } of newest) {
|
|
261
|
+
const tail = await readTail(file, ROLLOUT_TAIL_BYTES).catch(() => "");
|
|
262
|
+
if (!tail)
|
|
263
|
+
continue;
|
|
264
|
+
const found = findLatestCodexSnapshot(tail);
|
|
265
|
+
if (!found)
|
|
266
|
+
continue;
|
|
267
|
+
const observedAtMs = found.observedAtMs ?? mtimeMs;
|
|
268
|
+
const windows = parseCodexRateLimitSnapshot(found.snapshot, observedAtMs, nowMs);
|
|
269
|
+
if (windows.length === 0)
|
|
270
|
+
continue;
|
|
271
|
+
const observedAt = isoFromEpochMs(observedAtMs, nowMs);
|
|
272
|
+
if (!observedAt)
|
|
273
|
+
continue;
|
|
274
|
+
return { provider: "codex", source: "codex-rollout", observed_at: observedAt, windows };
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
// --------------------------------------------------------------------------
|
|
279
|
+
// Claude Code — cached utilization in the global config
|
|
280
|
+
// --------------------------------------------------------------------------
|
|
281
|
+
/**
|
|
282
|
+
* Claude Code's five-hour and weekly windows reach disk only through
|
|
283
|
+
* `cachedUsageUtilization` in its global config, which it refreshes when a
|
|
284
|
+
* human runs `/usage` on this machine. Headless `claude -p` sessions — the ones
|
|
285
|
+
* the runtime dispatches — never write it, so an absent or stale reading is the
|
|
286
|
+
* normal case and is reported as such rather than back-filled from token counts.
|
|
287
|
+
*
|
|
288
|
+
* On this path `utilization` is a percentage (0-100) and `resets_at` an ISO
|
|
289
|
+
* string. Claude Code keeps a *second*, header-derived store in different units
|
|
290
|
+
* — a 0-1 fraction with an epoch-seconds reset — which it exposes to statusline
|
|
291
|
+
* scripts. Reading one as the other silently misreports usage by 100×, so a
|
|
292
|
+
* numeric `resets_at` is taken as proof we are looking at the wrong shape and
|
|
293
|
+
* the window is discarded rather than converted on a guess.
|
|
294
|
+
*/
|
|
295
|
+
function parseClaudeUsageCache(value, nowMs) {
|
|
296
|
+
if (!value || typeof value !== "object")
|
|
297
|
+
return null;
|
|
298
|
+
const cache = value;
|
|
299
|
+
const fetchedAtMs = cache.fetchedAtMs;
|
|
300
|
+
if (typeof fetchedAtMs !== "number" || !Number.isFinite(fetchedAtMs))
|
|
301
|
+
return null;
|
|
302
|
+
// A reading from the future is a broken clock, not a fresh observation.
|
|
303
|
+
if (fetchedAtMs > nowMs + 5 * 60 * 1000)
|
|
304
|
+
return null;
|
|
305
|
+
if (!isoFromEpochMs(fetchedAtMs, nowMs))
|
|
306
|
+
return null;
|
|
307
|
+
const utilization = cache.utilization;
|
|
308
|
+
if (!utilization || typeof utilization !== "object")
|
|
309
|
+
return null;
|
|
310
|
+
const byWindow = utilization;
|
|
311
|
+
const windows = [];
|
|
312
|
+
for (const [key, windowMinutes] of [
|
|
313
|
+
["five_hour", 300],
|
|
314
|
+
["seven_day", 10080],
|
|
315
|
+
]) {
|
|
316
|
+
const entry = byWindow[key];
|
|
317
|
+
if (!entry || typeof entry !== "object")
|
|
318
|
+
continue;
|
|
319
|
+
const raw = entry;
|
|
320
|
+
if (typeof raw.resets_at === "number")
|
|
321
|
+
continue;
|
|
322
|
+
const parsed = buildWindow(normalizeUsedPercent(raw.utilization), windowMinutes, isoFromString(raw.resets_at, nowMs));
|
|
323
|
+
if (parsed)
|
|
324
|
+
windows.push(parsed);
|
|
325
|
+
}
|
|
326
|
+
if (windows.length === 0)
|
|
327
|
+
return null;
|
|
328
|
+
return { observedAtMs: fetchedAtMs, windows: windows.slice(0, MAX_WINDOWS_PER_PROVIDER) };
|
|
329
|
+
}
|
|
330
|
+
async function collectClaudeUsage(options) {
|
|
331
|
+
const nowMs = (options.now ?? Date.now)();
|
|
332
|
+
const stat = await node_fs_1.promises.stat(options.configPath).catch(() => null);
|
|
333
|
+
if (!stat || !stat.isFile() || stat.size > MAX_CLAUDE_CONFIG_BYTES)
|
|
334
|
+
return null;
|
|
335
|
+
const raw = await node_fs_1.promises.readFile(options.configPath, "utf8").catch(() => null);
|
|
336
|
+
if (!raw)
|
|
337
|
+
return null;
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(raw);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (!parsed || typeof parsed !== "object")
|
|
346
|
+
return null;
|
|
347
|
+
const found = parseClaudeUsageCache(parsed.cachedUsageUtilization, nowMs);
|
|
348
|
+
if (!found)
|
|
349
|
+
return null;
|
|
350
|
+
const observedAt = isoFromEpochMs(found.observedAtMs, nowMs);
|
|
351
|
+
if (!observedAt)
|
|
352
|
+
return null;
|
|
353
|
+
return {
|
|
354
|
+
provider: "claude-code",
|
|
355
|
+
source: "claude-usage-cache",
|
|
356
|
+
observed_at: observedAt,
|
|
357
|
+
windows: found.windows,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const DEFAULT_COLLECT_TIMEOUT_MS = 5_000;
|
|
361
|
+
/**
|
|
362
|
+
* Gathers every provider report available on this machine. Providers are read
|
|
363
|
+
* independently so one unreadable or malformed source cannot suppress another,
|
|
364
|
+
* and any thrown error degrades to "no report" rather than failing the caller.
|
|
365
|
+
*/
|
|
366
|
+
async function collectAgentUsage(options) {
|
|
367
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COLLECT_TIMEOUT_MS;
|
|
368
|
+
let timer;
|
|
369
|
+
const deadline = new Promise((resolve) => {
|
|
370
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
371
|
+
// Never hold the process open on account of usage collection.
|
|
372
|
+
timer.unref?.();
|
|
373
|
+
});
|
|
374
|
+
try {
|
|
375
|
+
const collection = Promise.allSettled([
|
|
376
|
+
collectCodexUsage({ codexHome: options.codexHome, now: options.now }),
|
|
377
|
+
collectClaudeUsage({ configPath: options.claudeConfigPath, now: options.now }),
|
|
378
|
+
]);
|
|
379
|
+
const settled = await Promise.race([collection, deadline]);
|
|
380
|
+
if (!settled) {
|
|
381
|
+
log.warn(`usage collection timed out after ${timeoutMs}ms`);
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
const reports = [];
|
|
385
|
+
for (const result of settled) {
|
|
386
|
+
if (result.status === "fulfilled") {
|
|
387
|
+
if (result.value)
|
|
388
|
+
reports.push(result.value);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
log.warn(`usage collection failed: ${String(result.reason)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return reports;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
if (timer)
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
}
|
|
400
|
+
}
|
package/package.json
CHANGED