taskchef 7.22.2 → 7.22.4
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/.codex-plugin/plugin.json +1 -1
- package/README.md +15 -7
- package/docs/spec.md +20 -8
- package/package.json +5 -2
- package/src/dashboard/app.js +9 -1
- package/src/resolve-ccusage.js +17 -0
- package/src/usage.js +336 -29
package/README.md
CHANGED
|
@@ -25,11 +25,15 @@ request -> recorded TaskChef task -> Codex executor -> request/result turn timel
|
|
|
25
25
|
TaskChef requires Node.js 18 or newer, Git, Codex desktop, and local access to
|
|
26
26
|
the projects that will receive work.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
TaskChef pins [`ccusage`](https://github.com/ccusage/ccusage) as an optional
|
|
29
|
+
dependency for dashboard token and API-equivalent cost estimates. A normal npm
|
|
30
|
+
installation uses that package-local executable; plugin-only installations use
|
|
31
|
+
an npm-cache-friendly `npx` fallback to resolve the same exact version's native
|
|
32
|
+
executable. `npx` never launches the analyzer against Codex data. TaskChef then
|
|
33
|
+
requests ccusage's live-capable online pricing and falls back to its bundled
|
|
34
|
+
offline snapshot, while never parsing Codex rollout files itself. Lifecycle
|
|
35
|
+
reporting and the dashboard continue to work when ccusage, npm, or pricing is
|
|
36
|
+
unavailable.
|
|
33
37
|
|
|
34
38
|
```sh
|
|
35
39
|
codex plugin marketplace add favoyang/codex-plugins
|
|
@@ -318,8 +322,12 @@ the terminal lifecycle callback precedes Codex's final output write. Historical
|
|
|
318
322
|
tasks may show a trustworthy task total while older turns remain “Tokens
|
|
319
323
|
unavailable” when no cumulative turn boundaries were recorded. Input, cached
|
|
320
324
|
input, output, reasoning, and total counts retain ccusage's categories. Dollar
|
|
321
|
-
figures are labeled API-equivalent estimates;
|
|
322
|
-
|
|
325
|
+
figures are labeled API-equivalent estimates; provenance identifies the online
|
|
326
|
+
pricing request or offline fallback mode, and zero-priced unknown models show
|
|
327
|
+
cost unavailable rather than a misleading `$0.00`. Per-turn cost is also
|
|
328
|
+
unavailable when its cumulative boundaries used different ccusage versions or
|
|
329
|
+
pricing modes. ccusage 20.0.20 may omit GPT-5.6 cache-write charges, so TaskChef
|
|
330
|
+
labels that analyzer limitation instead of presenting the estimate as complete.
|
|
323
331
|
The header shows the running TaskChef package version reported by the same
|
|
324
332
|
bounded health identity used for compatible-listener checks.
|
|
325
333
|
The canonical port is owned by a dashboard initialized in the TaskChef MCP host
|
package/docs/spec.md
CHANGED
|
@@ -120,11 +120,17 @@ aliases for existing callers.
|
|
|
120
120
|
|
|
121
121
|
## Optional usage projection
|
|
122
122
|
|
|
123
|
-
TaskChef
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
TaskChef MUST pin one tested `ccusage` version as an optional dependency. It
|
|
124
|
+
MUST prefer that package-local executable and MAY use an exact-version,
|
|
125
|
+
cache-friendly `npx` invocation when a plugin-only installation has no local
|
|
126
|
+
dependency. The `npx` invocation MUST only resolve the pinned native executable
|
|
127
|
+
and MUST NOT launch the analyzer against Codex data. TaskChef MUST execute that
|
|
128
|
+
native binary directly. The adapter MUST first request structured Codex output
|
|
129
|
+
with live-capable online pricing and retry with ccusage's bundled offline
|
|
130
|
+
pricing after a bounded failure. TaskChef MUST NOT parse, store, or serve raw
|
|
131
|
+
Codex rollout files, prompts, responses, transcripts, or reasoning. Analyzer,
|
|
132
|
+
npm, network, timeout, malformed output, unknown pricing, or an unresolved
|
|
133
|
+
thread MUST NOT block lifecycle tools or dashboard loading.
|
|
128
134
|
|
|
129
135
|
Dashboard-manual turns are administrative events, not Codex executions. They
|
|
130
136
|
MUST immediately report per-turn usage as unavailable, MUST NOT schedule or
|
|
@@ -162,9 +168,15 @@ in progress before its terminal report; a first historical terminal turn MUST
|
|
|
162
168
|
remain unavailable even when its cumulative task total is resolvable.
|
|
163
169
|
|
|
164
170
|
Every available projection MUST identify ccusage, its version when available,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
the requested online or fallback offline pricing mode, and usage freshness.
|
|
172
|
+
The online mode records TaskChef's request, not a claim about ccusage's internal
|
|
173
|
+
cache. Dollar values MUST be labeled API-equivalent estimates. Positive token
|
|
174
|
+
usage with a zero or missing analyzer cost MUST display cost unavailable, not
|
|
175
|
+
`$0.00`. A per-turn dollar delta MUST be unavailable when its adjacent
|
|
176
|
+
cumulative boundaries use different analyzer versions, requested pricing modes,
|
|
177
|
+
or declared cost coverage. For GPT-5.6 with ccusage 20.0.20, provenance MUST
|
|
178
|
+
disclose unverified cache-write coverage and TaskChef MUST NOT claim that the
|
|
179
|
+
estimate includes those charges.
|
|
168
180
|
|
|
169
181
|
Task IDs and non-null thread identities MUST be unique. The immutable intent
|
|
170
182
|
fields MUST NOT change after recording.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "7.22.
|
|
3
|
+
"version": "7.22.4",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -64,5 +64,8 @@
|
|
|
64
64
|
"@modelcontextprotocol/sdk",
|
|
65
65
|
"proper-lockfile",
|
|
66
66
|
"zod"
|
|
67
|
-
]
|
|
67
|
+
],
|
|
68
|
+
"optionalDependencies": {
|
|
69
|
+
"ccusage": "20.0.20"
|
|
70
|
+
}
|
|
68
71
|
}
|
package/src/dashboard/app.js
CHANGED
|
@@ -429,8 +429,16 @@ function usagePresentation(usage, { wholeTask = false } = {}) {
|
|
|
429
429
|
container.append(headline, breakdown);
|
|
430
430
|
const provenance = document.createElement("span");
|
|
431
431
|
const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
|
|
432
|
+
const pricingMode = usage.provenance?.pricingMode
|
|
433
|
+
? ` · ${usage.provenance.pricingMode === "online"
|
|
434
|
+
? "online pricing requested"
|
|
435
|
+
: "offline pricing fallback"}`
|
|
436
|
+
: "";
|
|
432
437
|
const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
|
|
433
|
-
provenance.textContent = `Source: ccusage${version}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
|
|
438
|
+
provenance.textContent = `Source: ccusage${version}${pricingMode}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
|
|
439
|
+
if (usage.provenance?.costCoverage === "cache_writes_unverified") {
|
|
440
|
+
provenance.textContent += " ccusage may omit GPT-5.6 cache-write charges.";
|
|
441
|
+
}
|
|
434
442
|
container.append(provenance);
|
|
435
443
|
return container;
|
|
436
444
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const [packageName, binaryName] = process.argv.slice(2);
|
|
5
|
+
if (!/^@ccusage\/ccusage-(?:darwin|linux|win32)-(?:arm64|x64)$/u.test(packageName ?? "")
|
|
6
|
+
|| !/^ccusage(?:\.exe)?$/u.test(binaryName ?? "")) {
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const binDirectory = process.env.PATH
|
|
11
|
+
?.split(path.delimiter)
|
|
12
|
+
.find((entry) => entry.endsWith(path.join("node_modules", ".bin")));
|
|
13
|
+
const binary = binDirectory
|
|
14
|
+
? path.join(path.dirname(binDirectory), packageName, "bin", binaryName)
|
|
15
|
+
: null;
|
|
16
|
+
if (binary === null || !existsSync(binary)) process.exit(1);
|
|
17
|
+
process.stdout.write(binary);
|
package/src/usage.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants as fsConstants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
openSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
3
12
|
import path from "node:path";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
6
15
|
|
|
7
|
-
const
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const taskchefPackage = require("../package.json");
|
|
18
|
+
const PINNED_CCUSAGE_VERSION = taskchefPackage.optionalDependencies?.ccusage;
|
|
19
|
+
const CCUSAGE_RESOLVER = fileURLToPath(new URL("./resolve-ccusage.js", import.meta.url));
|
|
20
|
+
let npxResolvedCcusageInvocation = null;
|
|
8
21
|
const USAGE_FILE_NAME = ".taskchef-usage.json";
|
|
9
22
|
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
|
10
23
|
const TOKEN_FIELDS = [
|
|
@@ -76,6 +89,13 @@ function normalizeProvenance(value, name, { includeSessionCount = false } = {})
|
|
|
76
89
|
return {
|
|
77
90
|
provider: "ccusage",
|
|
78
91
|
version: typeof value.version === "string" ? value.version.slice(0, 64) : null,
|
|
92
|
+
pricingMode: value.pricingMode === "online" || value.pricingMode === "offline"
|
|
93
|
+
? value.pricingMode
|
|
94
|
+
: null,
|
|
95
|
+
costCoverage: value.costCoverage === "cache_writes_unverified"
|
|
96
|
+
|| value.costCoverage === "ccusage_reported"
|
|
97
|
+
? value.costCoverage
|
|
98
|
+
: null,
|
|
79
99
|
...(includeSessionCount ? {
|
|
80
100
|
sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
|
|
81
101
|
} : {}),
|
|
@@ -107,6 +127,10 @@ function normalizeStoredSnapshot(value, name) {
|
|
|
107
127
|
}
|
|
108
128
|
snapshot.models = models;
|
|
109
129
|
snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
|
|
130
|
+
if (snapshot.provenance.pricingMode === null || snapshot.provenance.costCoverage === null) {
|
|
131
|
+
snapshot.estimatedCostUsd = null;
|
|
132
|
+
snapshot.costStatus = "unavailable";
|
|
133
|
+
}
|
|
110
134
|
snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
|
|
111
135
|
snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
|
|
112
136
|
return snapshot;
|
|
@@ -125,11 +149,15 @@ function normalizeStoredTurn(value, name) {
|
|
|
125
149
|
normalized.estimatedCostUsd = value.estimatedCostUsd === null
|
|
126
150
|
? null
|
|
127
151
|
: nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
|
|
152
|
+
const provenance = normalizeProvenance(value.provenance, name);
|
|
153
|
+
if (provenance.pricingMode === null || provenance.costCoverage === null) {
|
|
154
|
+
normalized.estimatedCostUsd = null;
|
|
155
|
+
}
|
|
128
156
|
return {
|
|
129
157
|
status: "available",
|
|
130
158
|
...normalized,
|
|
131
159
|
costStatus: normalized.estimatedCostUsd === null ? "unavailable" : "estimated",
|
|
132
|
-
provenance
|
|
160
|
+
provenance,
|
|
133
161
|
sampledAt: timestampOrNull(value.sampledAt, `${name}.sampledAt`),
|
|
134
162
|
sourceUpdatedAt: timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`),
|
|
135
163
|
updatedAt,
|
|
@@ -196,6 +224,7 @@ function normalizeStoredRecord(value, name) {
|
|
|
196
224
|
export function aggregateCcusageSessions(payload, threadId, {
|
|
197
225
|
sampledAt = new Date().toISOString(),
|
|
198
226
|
version = null,
|
|
227
|
+
pricingMode = null,
|
|
199
228
|
} = {}) {
|
|
200
229
|
if (!payload || typeof payload !== "object" || !Array.isArray(payload.sessions)) {
|
|
201
230
|
throw new Error("ccusage output must contain a sessions array");
|
|
@@ -241,6 +270,10 @@ export function aggregateCcusageSessions(payload, threadId, {
|
|
|
241
270
|
provenance: {
|
|
242
271
|
provider: "ccusage",
|
|
243
272
|
version,
|
|
273
|
+
pricingMode,
|
|
274
|
+
costCoverage: Object.keys(models).some((model) => /^gpt-5\.6(?:-|$)/i.test(model))
|
|
275
|
+
? "cache_writes_unverified"
|
|
276
|
+
: "ccusage_reported",
|
|
244
277
|
sessionCount: sessions.length,
|
|
245
278
|
},
|
|
246
279
|
sampledAt,
|
|
@@ -248,41 +281,312 @@ export function aggregateCcusageSessions(payload, threadId, {
|
|
|
248
281
|
};
|
|
249
282
|
}
|
|
250
283
|
|
|
251
|
-
export
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
284
|
+
export function managedCcusageInvocation({
|
|
285
|
+
platform = process.platform,
|
|
286
|
+
arch = process.arch,
|
|
287
|
+
env = process.env,
|
|
288
|
+
resolvePath = (id) => require.resolve(id),
|
|
289
|
+
exists = existsSync,
|
|
290
|
+
npxCliPath = null,
|
|
256
291
|
} = {}) {
|
|
257
|
-
|
|
292
|
+
if (typeof PINNED_CCUSAGE_VERSION !== "string" || PINNED_CCUSAGE_VERSION.length === 0) {
|
|
293
|
+
throw new Error("TaskChef has no pinned ccusage version");
|
|
294
|
+
}
|
|
295
|
+
const packageName = {
|
|
296
|
+
"darwin-arm64": "@ccusage/ccusage-darwin-arm64",
|
|
297
|
+
"darwin-x64": "@ccusage/ccusage-darwin-x64",
|
|
298
|
+
"linux-arm64": "@ccusage/ccusage-linux-arm64",
|
|
299
|
+
"linux-x64": "@ccusage/ccusage-linux-x64",
|
|
300
|
+
"win32-arm64": "@ccusage/ccusage-win32-arm64",
|
|
301
|
+
"win32-x64": "@ccusage/ccusage-win32-x64",
|
|
302
|
+
}[`${platform}-${arch}`];
|
|
303
|
+
let nativeBinary = null;
|
|
304
|
+
if (packageName !== undefined) {
|
|
305
|
+
try {
|
|
306
|
+
nativeBinary = resolvePath(
|
|
307
|
+
`${packageName}/bin/${platform === "win32" ? "ccusage.exe" : "ccusage"}`,
|
|
308
|
+
);
|
|
309
|
+
} catch {
|
|
310
|
+
// Plugin-only installations can resolve the same pinned release through npx.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (nativeBinary !== null) {
|
|
314
|
+
return {
|
|
315
|
+
command: nativeBinary,
|
|
316
|
+
args: [],
|
|
317
|
+
version: PINNED_CCUSAGE_VERSION,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (packageName === undefined) {
|
|
321
|
+
throw new Error(`ccusage does not support ${platform}-${arch}`);
|
|
322
|
+
}
|
|
323
|
+
const binaryName = platform === "win32" ? "ccusage.exe" : "ccusage";
|
|
324
|
+
const npxArgs = [
|
|
325
|
+
"--yes",
|
|
326
|
+
"--prefer-offline",
|
|
327
|
+
`--package=ccusage@${PINNED_CCUSAGE_VERSION}`,
|
|
328
|
+
"node",
|
|
329
|
+
CCUSAGE_RESOLVER,
|
|
330
|
+
packageName,
|
|
331
|
+
binaryName,
|
|
332
|
+
];
|
|
333
|
+
if (platform === "win32") {
|
|
334
|
+
const platformPath = path.win32;
|
|
335
|
+
const nodeDirectory = platformPath.dirname(process.execPath);
|
|
336
|
+
const candidates = [
|
|
337
|
+
npxCliPath,
|
|
338
|
+
typeof env.npm_execpath === "string"
|
|
339
|
+
? platformPath.join(platformPath.dirname(env.npm_execpath), "npx-cli.js")
|
|
340
|
+
: null,
|
|
341
|
+
platformPath.join(nodeDirectory, "node_modules", "npm", "bin", "npx-cli.js"),
|
|
342
|
+
platformPath.resolve(
|
|
343
|
+
nodeDirectory,
|
|
344
|
+
"..", "lib", "node_modules", "npm", "bin", "npx-cli.js",
|
|
345
|
+
),
|
|
346
|
+
];
|
|
347
|
+
const npxCli = candidates.find((candidate) => typeof candidate === "string"
|
|
348
|
+
&& platformPath.isAbsolute(candidate)
|
|
349
|
+
&& exists(candidate));
|
|
350
|
+
if (npxCli === undefined) throw new Error("TaskChef could not resolve npm's npx CLI safely");
|
|
351
|
+
return {
|
|
352
|
+
command: process.execPath,
|
|
353
|
+
args: [npxCli, ...npxArgs],
|
|
354
|
+
version: PINNED_CCUSAGE_VERSION,
|
|
355
|
+
resolvesNative: true,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
command: "npx",
|
|
360
|
+
args: npxArgs,
|
|
361
|
+
version: PINNED_CCUSAGE_VERSION,
|
|
362
|
+
resolvesNative: true,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function ensureCcusageExecutable(command, {
|
|
367
|
+
platform = process.platform,
|
|
368
|
+
} = {}) {
|
|
369
|
+
if (platform === "win32") return;
|
|
370
|
+
const descriptor = openSync(command, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
258
371
|
try {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
372
|
+
const file = fstatSync(descriptor);
|
|
373
|
+
if (!file.isFile()) throw new Error("ccusage native binary is not a regular file");
|
|
374
|
+
if ((file.mode & 0o100) === 0) fchmodSync(descriptor, file.mode | 0o100);
|
|
375
|
+
} finally {
|
|
376
|
+
closeSync(descriptor);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function resolveManagedCcusageInvocation(invocation, run, timeoutMs) {
|
|
381
|
+
if (invocation.resolvesNative !== true) {
|
|
382
|
+
ensureCcusageExecutable(invocation.command);
|
|
383
|
+
return invocation;
|
|
384
|
+
}
|
|
385
|
+
if (npxResolvedCcusageInvocation !== null
|
|
386
|
+
&& existsSync(npxResolvedCcusageInvocation.command)) {
|
|
387
|
+
ensureCcusageExecutable(npxResolvedCcusageInvocation.command);
|
|
388
|
+
return npxResolvedCcusageInvocation;
|
|
389
|
+
}
|
|
390
|
+
npxResolvedCcusageInvocation = null;
|
|
391
|
+
const result = await run(invocation.command, invocation.args, {
|
|
392
|
+
timeout: timeoutMs,
|
|
393
|
+
maxBuffer: 64 * 1024,
|
|
394
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
395
|
+
});
|
|
396
|
+
const command = String(result.stdout).trim();
|
|
397
|
+
if (!path.isAbsolute(command)
|
|
398
|
+
|| !/^ccusage(?:\.exe)?$/iu.test(path.basename(command))
|
|
399
|
+
|| !existsSync(command)) {
|
|
400
|
+
throw new Error("npx returned an invalid ccusage executable");
|
|
401
|
+
}
|
|
402
|
+
ensureCcusageExecutable(command);
|
|
403
|
+
npxResolvedCcusageInvocation = {
|
|
404
|
+
command,
|
|
405
|
+
args: [],
|
|
406
|
+
version: invocation.version,
|
|
407
|
+
};
|
|
408
|
+
return npxResolvedCcusageInvocation;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function processTable() {
|
|
412
|
+
return new Promise((resolve) => {
|
|
413
|
+
execFileCallback("ps", ["-axo", "pid=,ppid="], {
|
|
414
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
415
|
+
timeout: 250,
|
|
416
|
+
windowsHide: true,
|
|
417
|
+
}, (error, stdout) => {
|
|
418
|
+
if (error) {
|
|
419
|
+
resolve([]);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
resolve(String(stdout).trim().split("\n").flatMap((line) => {
|
|
423
|
+
const [pid, parentPid] = line.trim().split(/\s+/u).map(Number);
|
|
424
|
+
return Number.isInteger(pid) && Number.isInteger(parentPid)
|
|
425
|
+
? [{ pid, parentPid }]
|
|
426
|
+
: [];
|
|
427
|
+
}));
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function descendantProcessIds(parentPid) {
|
|
433
|
+
const table = await processTable();
|
|
434
|
+
const visit = (pid) => table
|
|
435
|
+
.filter((entry) => entry.parentPid === pid)
|
|
436
|
+
.flatMap((entry) => [...visit(entry.pid), entry.pid]);
|
|
437
|
+
return visit(parentPid);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function terminateProcessTree(child, platform = process.platform) {
|
|
441
|
+
if (!Number.isInteger(child.pid)) return;
|
|
442
|
+
if (platform === "win32") {
|
|
443
|
+
await new Promise((resolve) => {
|
|
444
|
+
execFileCallback(
|
|
445
|
+
"taskkill",
|
|
446
|
+
["/pid", String(child.pid), "/t", "/f"],
|
|
447
|
+
{ timeout: 500, windowsHide: true },
|
|
448
|
+
() => {
|
|
449
|
+
child.kill("SIGKILL");
|
|
450
|
+
resolve();
|
|
451
|
+
},
|
|
452
|
+
);
|
|
262
453
|
});
|
|
263
|
-
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
process.kill(-child.pid, "SIGKILL");
|
|
458
|
+
return;
|
|
264
459
|
} catch {
|
|
265
|
-
//
|
|
460
|
+
// Resolve descendants only when process-group termination is unavailable.
|
|
266
461
|
}
|
|
267
|
-
let
|
|
462
|
+
let descendants = [];
|
|
268
463
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
464
|
+
descendants = await descendantProcessIds(child.pid);
|
|
465
|
+
} catch {
|
|
466
|
+
// The detached process-group kill remains the fallback when ps is unavailable.
|
|
467
|
+
}
|
|
468
|
+
for (const pid of descendants) {
|
|
469
|
+
try {
|
|
470
|
+
process.kill(pid, "SIGKILL");
|
|
471
|
+
} catch {
|
|
472
|
+
// The group kill or normal process exit may have won the race.
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
child.kill("SIGKILL");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function runBoundedProcess(command, args, options = {}) {
|
|
479
|
+
const platform = options.platform ?? process.platform;
|
|
480
|
+
const timeout = options.timeout ?? 0;
|
|
481
|
+
const { platform: _platform, ...childOptions } = options;
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
let timedOut = false;
|
|
484
|
+
let timer = null;
|
|
485
|
+
let settled = false;
|
|
486
|
+
const rejectOnce = (error) => {
|
|
487
|
+
if (settled) return;
|
|
488
|
+
settled = true;
|
|
489
|
+
reject(error);
|
|
490
|
+
};
|
|
491
|
+
const child = execFileCallback(command, args, {
|
|
492
|
+
...childOptions,
|
|
493
|
+
timeout: undefined,
|
|
494
|
+
detached: platform !== "win32",
|
|
495
|
+
windowsHide: true,
|
|
496
|
+
}, async (error, stdout, stderr) => {
|
|
497
|
+
if (timer !== null) clearTimeout(timer);
|
|
498
|
+
if (timedOut || settled) return;
|
|
499
|
+
if (error) {
|
|
500
|
+
await terminateProcessTree(child, platform);
|
|
501
|
+
rejectOnce(error);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
settled = true;
|
|
505
|
+
resolve({ stdout, stderr });
|
|
273
506
|
});
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
507
|
+
if (timeout > 0) {
|
|
508
|
+
timer = setTimeout(() => {
|
|
509
|
+
timedOut = true;
|
|
510
|
+
const timeoutError = new Error(`${command} timed out`);
|
|
511
|
+
timeoutError.code = "ETIMEDOUT";
|
|
512
|
+
timeoutError.killed = true;
|
|
513
|
+
Promise.race([
|
|
514
|
+
terminateProcessTree(child, platform),
|
|
515
|
+
new Promise((resolveTermination) => setTimeout(resolveTermination, 750)),
|
|
516
|
+
]).catch(() => {}).then(() => rejectOnce(timeoutError));
|
|
517
|
+
}, timeout);
|
|
518
|
+
timer.unref();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function readCcusageThreadUsage(threadId, {
|
|
524
|
+
command = null,
|
|
525
|
+
commandArgs = [],
|
|
526
|
+
run = runBoundedProcess,
|
|
527
|
+
sampledAt = new Date().toISOString(),
|
|
528
|
+
timeoutMs = 8_000,
|
|
529
|
+
} = {}) {
|
|
530
|
+
const invocation = command === null
|
|
531
|
+
? managedCcusageInvocation()
|
|
532
|
+
: { command, args: commandArgs, version: null };
|
|
533
|
+
const resolvedInvocation = command === null
|
|
534
|
+
? await resolveManagedCcusageInvocation(invocation, run, timeoutMs)
|
|
535
|
+
: invocation;
|
|
536
|
+
const invoke = (args, options) => run(
|
|
537
|
+
resolvedInvocation.command,
|
|
538
|
+
[...resolvedInvocation.args, ...args],
|
|
539
|
+
options,
|
|
540
|
+
);
|
|
541
|
+
let version = resolvedInvocation.version;
|
|
542
|
+
if (version === null) {
|
|
543
|
+
try {
|
|
544
|
+
const result = await invoke(["--version"], {
|
|
545
|
+
timeout: Math.min(timeoutMs, 2_000),
|
|
546
|
+
maxBuffer: 64 * 1024,
|
|
547
|
+
});
|
|
548
|
+
version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
|
|
549
|
+
} catch {
|
|
550
|
+
// Usage remains useful if a custom compatible ccusage cannot print its version.
|
|
551
|
+
}
|
|
278
552
|
}
|
|
279
|
-
|
|
553
|
+
const report = async (pricingMode) => {
|
|
554
|
+
let result;
|
|
555
|
+
try {
|
|
556
|
+
result = await invoke([
|
|
557
|
+
"codex", "session", "--json",
|
|
558
|
+
pricingMode === "online" ? "--no-offline" : "--offline",
|
|
559
|
+
], {
|
|
560
|
+
timeout: timeoutMs,
|
|
561
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
562
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
563
|
+
});
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error?.code === "ENOENT") throw new Error("ccusage is not installed");
|
|
566
|
+
if (error?.killed || error?.code === "ETIMEDOUT") throw new Error("ccusage timed out");
|
|
567
|
+
throw new Error("ccusage could not read Codex usage");
|
|
568
|
+
}
|
|
569
|
+
let payload;
|
|
570
|
+
try {
|
|
571
|
+
payload = JSON.parse(result.stdout);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new Error("ccusage returned malformed JSON");
|
|
574
|
+
}
|
|
575
|
+
return aggregateCcusageSessions(payload, threadId, {
|
|
576
|
+
sampledAt,
|
|
577
|
+
version,
|
|
578
|
+
pricingMode,
|
|
579
|
+
});
|
|
580
|
+
};
|
|
280
581
|
try {
|
|
281
|
-
|
|
282
|
-
} catch {
|
|
283
|
-
|
|
582
|
+
return await report("online");
|
|
583
|
+
} catch (onlineError) {
|
|
584
|
+
try {
|
|
585
|
+
return await report("offline");
|
|
586
|
+
} catch {
|
|
587
|
+
throw onlineError;
|
|
588
|
+
}
|
|
284
589
|
}
|
|
285
|
-
return aggregateCcusageSessions(payload, threadId, { sampledAt, version });
|
|
286
590
|
}
|
|
287
591
|
|
|
288
592
|
function usageFile(workspace) {
|
|
@@ -372,7 +676,10 @@ export function usageDelta(current, previous = null) {
|
|
|
372
676
|
if (!Number.isFinite(value) || value < 0) return null;
|
|
373
677
|
delta[field] = value;
|
|
374
678
|
}
|
|
375
|
-
|
|
679
|
+
const samePricingSource = previous === null || ["version", "pricingMode", "costCoverage"]
|
|
680
|
+
.every((field) => current.provenance?.[field] === previous.provenance?.[field]);
|
|
681
|
+
let estimatedCostUsd = !samePricingSource
|
|
682
|
+
|| current.estimatedCostUsd === null
|
|
376
683
|
|| (previous !== null && previous.estimatedCostUsd === null)
|
|
377
684
|
? null
|
|
378
685
|
: current.estimatedCostUsd - (previous?.estimatedCostUsd ?? 0);
|