@kairyou/agent-tools 0.9.0 → 0.10.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 +36 -108
- package/README.zh-CN.md +34 -97
- package/dist/usage/cli.mjs +5 -3
- package/dist/usage/codex-hook.mjs +18 -10
- package/dist/usage/core.mjs +175 -94
- package/docs/en/custom-gateway-routes.md +53 -0
- package/docs/en/repository-structure.md +20 -0
- package/docs/zh-CN/custom-gateway-routes.md +47 -0
- package/docs/zh-CN/repository-structure.md +20 -0
- package/integrations/usage/codex-hook.mjs +19 -10
- package/integrations/usage/core.mjs +60 -12
- package/integrations/usage/lib/cache.mjs +152 -70
- package/integrations/usage/lib/config.mjs +11 -10
- package/integrations/usage/lib/http.mjs +5 -2
- package/package.json +2 -1
- package/scripts/install.mjs +13 -4
- package/scripts/publish.mjs +6 -2
- package/scripts/release.mjs +8 -6
|
@@ -7,11 +7,19 @@
|
|
|
7
7
|
// Everything else lives in ./lib (config, urls, http, cache, format, context,
|
|
8
8
|
// routes) and is bundled into dist/usage/core.mjs at build time.
|
|
9
9
|
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
|
-
import {
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
debugLog,
|
|
14
|
+
usagePreset,
|
|
15
|
+
HOOK_SNAPSHOT_MAX_AGE_MS,
|
|
16
|
+
REFRESH_INTERVAL_MS,
|
|
17
|
+
} from "./lib/config.mjs";
|
|
12
18
|
import { isOfficialBaseUrl } from "./lib/urls.mjs";
|
|
13
19
|
import {
|
|
14
20
|
readUsageSnapshot,
|
|
21
|
+
acquireUsageRefreshLease,
|
|
22
|
+
canRefreshUsage,
|
|
15
23
|
rememberUsageRoute,
|
|
16
24
|
rememberUsageSnapshot,
|
|
17
25
|
rememberRefreshState,
|
|
@@ -20,7 +28,7 @@ import { orderedUsageRoutes } from "./lib/routes.mjs";
|
|
|
20
28
|
import { usageContext, normalizeUsageContext } from "./lib/context.mjs";
|
|
21
29
|
|
|
22
30
|
function parseArgs(argv) {
|
|
23
|
-
const opts = { mode: "hook", agent: "codex" };
|
|
31
|
+
const opts = { mode: "hook", agent: "codex", silent: false };
|
|
24
32
|
let modeSet = false;
|
|
25
33
|
for (let i = 0; i < argv.length; i += 1) {
|
|
26
34
|
const arg = argv[i];
|
|
@@ -28,6 +36,8 @@ function parseArgs(argv) {
|
|
|
28
36
|
opts.agent = argv[++i];
|
|
29
37
|
} else if (arg.startsWith("--agent=")) {
|
|
30
38
|
opts.agent = arg.slice("--agent=".length);
|
|
39
|
+
} else if (arg === "--silent") {
|
|
40
|
+
opts.silent = true;
|
|
31
41
|
} else if (!arg.startsWith("-") && !modeSet) {
|
|
32
42
|
opts.mode = arg;
|
|
33
43
|
modeSet = true;
|
|
@@ -111,30 +121,68 @@ export async function queryProviderUsage(input, options = {}) {
|
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
async function refresh(agent = "codex") {
|
|
114
|
-
|
|
124
|
+
const context = await usageContext(agent);
|
|
125
|
+
const release = await acquireUsageRefreshLease(context);
|
|
126
|
+
if (!release) return { skipped: true, text: "" };
|
|
127
|
+
try {
|
|
128
|
+
if (!(await canRefreshUsage(context, REFRESH_INTERVAL_MS))) return { skipped: true, text: "" };
|
|
129
|
+
await rememberRefreshState(context, { lastStartedAt: new Date().toISOString() });
|
|
130
|
+
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
131
|
+
} finally {
|
|
132
|
+
await release();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function cachedUsage(context) {
|
|
137
|
+
const cached = await readUsageSnapshot(context);
|
|
138
|
+
const ageMs = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
|
|
139
|
+
return cached?.text && Number.isFinite(ageMs) ? { ...cached, ageMs } : null;
|
|
115
140
|
}
|
|
116
141
|
|
|
117
142
|
export async function queryAgentProviderUsage(agent = "codex", { maxAgeMs = 0 } = {}) {
|
|
118
143
|
const context = await usageContext(agent);
|
|
119
144
|
if (maxAgeMs > 0) {
|
|
120
|
-
const cached = await
|
|
121
|
-
|
|
122
|
-
if (cached?.text && age < maxAgeMs) return { ...cached, cached: true };
|
|
145
|
+
const cached = await cachedUsage(context);
|
|
146
|
+
if (cached && cached.ageMs < maxAgeMs) return { ...cached, cached: true };
|
|
123
147
|
}
|
|
124
148
|
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
function scheduleRefresh(agent) {
|
|
152
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "refresh", "--agent", agent], {
|
|
153
|
+
cwd: process.cwd(),
|
|
154
|
+
env: process.env,
|
|
155
|
+
detached: true,
|
|
156
|
+
stdio: "ignore",
|
|
157
|
+
windowsHide: true,
|
|
158
|
+
});
|
|
159
|
+
child.on("error", () => {});
|
|
160
|
+
child.unref();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function hook(agent, { silent = false } = {}) {
|
|
164
|
+
const context = await usageContext(agent);
|
|
165
|
+
const cached = await cachedUsage(context);
|
|
166
|
+
if (
|
|
167
|
+
(!cached || cached.ageMs >= REFRESH_INTERVAL_MS) &&
|
|
168
|
+
(await canRefreshUsage(context, REFRESH_INTERVAL_MS))
|
|
169
|
+
) {
|
|
170
|
+
scheduleRefresh(agent);
|
|
171
|
+
}
|
|
172
|
+
hookOut(silent ? "" : cached && cached.ageMs < HOOK_SNAPSHOT_MAX_AGE_MS ? cached.text : "");
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
async function main() {
|
|
128
176
|
try {
|
|
129
177
|
if (mode === "refresh") {
|
|
130
178
|
await refresh(cli.agent);
|
|
131
|
-
} else if (mode === "print"
|
|
132
|
-
|
|
179
|
+
} else if (mode === "print") {
|
|
180
|
+
// Explicit request: query directly instead of going through the
|
|
181
|
+
// background refresh throttle, which would print nothing.
|
|
182
|
+
const result = await queryAgentProviderUsage(cli.agent);
|
|
133
183
|
textOut(result?.text || "");
|
|
134
184
|
} else if (mode === "hook") {
|
|
135
|
-
|
|
136
|
-
const result = await queryAgentProviderUsage(cli.agent, { maxAgeMs: snapshotTtlMs() });
|
|
137
|
-
hookOut(result?.text || "");
|
|
185
|
+
await hook(cli.agent, { silent: cli.silent });
|
|
138
186
|
} else {
|
|
139
187
|
throw new Error(`unknown mode: ${mode}`);
|
|
140
188
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// On-disk state under ~/.agent-tools/cache: the last working route per
|
|
2
2
|
// gateway, the latest usage snapshot, and refresh bookkeeping.
|
|
3
3
|
|
|
4
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
5
|
-
import {
|
|
4
|
+
import { writeFile, mkdir, open, unlink, rename, stat, utimes } from "node:fs/promises";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
6
7
|
import {
|
|
8
|
+
CACHE_DIR,
|
|
7
9
|
ROUTE_CACHE_PATH,
|
|
8
10
|
SNAPSHOT_PATH,
|
|
9
11
|
REFRESH_STATE_PATH,
|
|
@@ -12,104 +14,184 @@ import {
|
|
|
12
14
|
} from "./config.mjs";
|
|
13
15
|
import { usageRouteCacheKey } from "./urls.mjs";
|
|
14
16
|
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const REFRESH_STATE_VERSION = 1;
|
|
17
|
+
const CACHE_VERSION = 1;
|
|
18
|
+
const WRITE_LOCK_PATH = join(CACHE_DIR, "usage-cache-write.lock");
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// Every cache file is { version, <field>: { [gatewayKey]: entry } }.
|
|
21
|
+
async function readJsonCache(path, field) {
|
|
20
22
|
try {
|
|
21
|
-
const raw = await readTextIfExists(
|
|
22
|
-
|
|
23
|
-
const parsed = JSON.parse(raw);
|
|
23
|
+
const raw = await readTextIfExists(path);
|
|
24
|
+
const entries = raw.trim() ? JSON.parse(raw)?.[field] : null;
|
|
24
25
|
return {
|
|
25
|
-
version:
|
|
26
|
-
|
|
26
|
+
version: CACHE_VERSION,
|
|
27
|
+
[field]: entries && typeof entries === "object" ? entries : {},
|
|
27
28
|
};
|
|
28
29
|
} catch {
|
|
29
|
-
return { version:
|
|
30
|
+
return { version: CACHE_VERSION, [field]: {} };
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
// Read-modify-write serialized across processes: the gateway refresh lock
|
|
35
|
+
// covers only one relay, and skill/CLI queries hold no lock at all, so
|
|
36
|
+
// concurrent writers would otherwise drop each other's entries. The temp file
|
|
37
|
+
// plus rename keeps readers from ever seeing a half-written file.
|
|
38
|
+
async function updateJsonCache(path, field, source, mutate) {
|
|
39
|
+
const release = await acquireWriteLock();
|
|
40
|
+
if (!release) {
|
|
41
|
+
// Losing one update is safer than an unlocked read-modify-write, which
|
|
42
|
+
// would drop the other gateways' entries. Caches refill on the next run.
|
|
43
|
+
await debugLog({ source, skipped: "cache write lock unavailable" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
34
46
|
try {
|
|
35
|
-
const cache = await
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
updatedAt: new Date().toISOString(),
|
|
42
|
-
};
|
|
43
|
-
await mkdir(dirname(ROUTE_CACHE_PATH), { recursive: true });
|
|
44
|
-
await writeFile(ROUTE_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`);
|
|
47
|
+
const cache = await readJsonCache(path, field);
|
|
48
|
+
mutate(cache[field]);
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
51
|
+
await writeFile(temp, `${JSON.stringify(cache, null, 2)}\n`);
|
|
52
|
+
await rename(temp, path);
|
|
45
53
|
} catch (error) {
|
|
46
|
-
await debugLog({ source
|
|
54
|
+
await debugLog({ source, error: error.message });
|
|
55
|
+
} finally {
|
|
56
|
+
await release();
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
async function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
export async function readRouteCache() {
|
|
61
|
+
return await readJsonCache(ROUTE_CACHE_PATH, "routes");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function rememberUsageRoute(context, route, result) {
|
|
65
|
+
await updateJsonCache(ROUTE_CACHE_PATH, "routes", "route-cache", (routes) => {
|
|
66
|
+
routes[usageRouteCacheKey(context.baseUrl)] = {
|
|
67
|
+
route: route.id,
|
|
68
|
+
path: route.path,
|
|
69
|
+
source: result.source,
|
|
70
|
+
updatedAt: new Date().toISOString(),
|
|
58
71
|
};
|
|
59
|
-
}
|
|
60
|
-
return { version: SNAPSHOT_VERSION, items: {} };
|
|
61
|
-
}
|
|
72
|
+
});
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
export async function readUsageSnapshot(context) {
|
|
65
|
-
const cache = await
|
|
76
|
+
const cache = await readJsonCache(SNAPSHOT_PATH, "items");
|
|
66
77
|
return cache.items[usageRouteCacheKey(context.baseUrl)] || null;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
export async function rememberUsageSnapshot(context, result) {
|
|
70
81
|
if (!result?.text) return;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const key = usageRouteCacheKey(context.baseUrl);
|
|
74
|
-
cache.items[key] = {
|
|
82
|
+
await updateJsonCache(SNAPSHOT_PATH, "items", "snapshot-cache", (items) => {
|
|
83
|
+
items[usageRouteCacheKey(context.baseUrl)] = {
|
|
75
84
|
text: result.text,
|
|
76
85
|
source: result.source,
|
|
77
86
|
baseUrl: context.baseUrl,
|
|
78
87
|
updatedAt: new Date().toISOString(),
|
|
79
88
|
};
|
|
80
|
-
|
|
81
|
-
await writeFile(SNAPSHOT_PATH, `${JSON.stringify(cache, null, 2)}\n`);
|
|
82
|
-
} catch (error) {
|
|
83
|
-
await debugLog({ source: "snapshot-cache", error: error.message });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function readRefreshState() {
|
|
88
|
-
try {
|
|
89
|
-
const raw = await readTextIfExists(REFRESH_STATE_PATH);
|
|
90
|
-
if (!raw.trim()) return { version: REFRESH_STATE_VERSION, items: {} };
|
|
91
|
-
const parsed = JSON.parse(raw);
|
|
92
|
-
return {
|
|
93
|
-
version: REFRESH_STATE_VERSION,
|
|
94
|
-
items: parsed?.items && typeof parsed.items === "object" ? parsed.items : {},
|
|
95
|
-
};
|
|
96
|
-
} catch {
|
|
97
|
-
return { version: REFRESH_STATE_VERSION, items: {} };
|
|
98
|
-
}
|
|
89
|
+
});
|
|
99
90
|
}
|
|
100
91
|
|
|
101
92
|
export async function rememberRefreshState(context, patch) {
|
|
102
|
-
|
|
103
|
-
const state = await readRefreshState();
|
|
93
|
+
await updateJsonCache(REFRESH_STATE_PATH, "items", "refresh-state", (items) => {
|
|
104
94
|
const key = usageRouteCacheKey(context.baseUrl);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
95
|
+
items[key] = { ...(items[key] || {}), ...patch, baseUrl: context.baseUrl };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function canRefreshUsage(context, minIntervalMs) {
|
|
100
|
+
if (minIntervalMs <= 0) return true;
|
|
101
|
+
const state = await readJsonCache(REFRESH_STATE_PATH, "items");
|
|
102
|
+
const item = state.items[usageRouteCacheKey(context.baseUrl)] || {};
|
|
103
|
+
const latest = Math.max(
|
|
104
|
+
...[item.lastStartedAt, item.lastSuccessAt, item.lastFailureAt]
|
|
105
|
+
.map((value) => Date.parse(value || ""))
|
|
106
|
+
.filter(Number.isFinite),
|
|
107
|
+
0
|
|
108
|
+
);
|
|
109
|
+
return latest === 0 || Date.now() - latest >= minIntervalMs;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `wx` fails when the file exists, which makes creating it an atomic
|
|
113
|
+
// cross-process lock. Returns a release function, or null when the lock is held.
|
|
114
|
+
//
|
|
115
|
+
// Best-effort, not a guarantee: the holder keeps touching the file so a live
|
|
116
|
+
// holder is unlikely to be declared stale, but a suspended machine, a stalled
|
|
117
|
+
// event loop, or a failing utimes can still make one miss its heartbeat. The
|
|
118
|
+
// owner token on release covers most of what slips through — a takeover that
|
|
119
|
+
// happens between reading the token and unlinking still isn't excluded, and a
|
|
120
|
+
// stale holder's heartbeat can even touch the new owner's file, since utimes
|
|
121
|
+
// works on the path rather than a handle. Good enough for a background usage
|
|
122
|
+
// cache, where the worst case is one duplicated refresh that self-heals.
|
|
123
|
+
async function tryLock(lockPath, staleMs, details = {}) {
|
|
124
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
125
|
+
const token = randomUUID();
|
|
126
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
127
|
+
try {
|
|
128
|
+
const handle = await open(lockPath, "wx");
|
|
129
|
+
try {
|
|
130
|
+
await handle.writeFile(
|
|
131
|
+
`${JSON.stringify({ token, pid: process.pid, ...details, startedAt: new Date().toISOString() })}\n`
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
await handle.close();
|
|
135
|
+
}
|
|
136
|
+
const heartbeat = setInterval(() => {
|
|
137
|
+
const now = new Date();
|
|
138
|
+
utimes(lockPath, now, now).catch(() => {});
|
|
139
|
+
}, Math.max(50, Math.floor(staleMs / 3)));
|
|
140
|
+
heartbeat.unref(); // must never keep the process alive
|
|
141
|
+
return async () => {
|
|
142
|
+
clearInterval(heartbeat);
|
|
143
|
+
// If we were taken over anyway, the file belongs to someone else and
|
|
144
|
+
// must survive; this check is why a missed heartbeat is recoverable.
|
|
145
|
+
try {
|
|
146
|
+
const held = JSON.parse(await readTextIfExists(lockPath));
|
|
147
|
+
if (held?.token === token) await unlink(lockPath);
|
|
148
|
+
} catch {
|
|
149
|
+
// Unreadable or already gone: nothing of ours left to release.
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error?.code !== "EEXIST" || attempt > 0) return null;
|
|
154
|
+
const age = await stat(lockPath).then(
|
|
155
|
+
({ mtimeMs }) => Date.now() - mtimeMs,
|
|
156
|
+
() => Infinity // vanished under us: retry immediately
|
|
157
|
+
);
|
|
158
|
+
if (age <= staleMs) return null;
|
|
159
|
+
// Claim the stale lock by renaming it: rename is atomic, so only one
|
|
160
|
+
// process wins and nobody deletes a lock another just created.
|
|
161
|
+
try {
|
|
162
|
+
const claimed = `${lockPath}.${token}`;
|
|
163
|
+
await rename(lockPath, claimed);
|
|
164
|
+
await unlink(claimed).catch(() => {});
|
|
165
|
+
} catch {
|
|
166
|
+
return null; // another process claimed it first
|
|
167
|
+
}
|
|
168
|
+
}
|
|
114
169
|
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One lock per gateway, keyed like the route and snapshot caches, so
|
|
174
|
+
// refreshing one relay never blocks another.
|
|
175
|
+
function refreshLockPath(context) {
|
|
176
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
177
|
+
const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
178
|
+
return join(CACHE_DIR, `usage-refresh-${hash}.lock`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The holder keeps its lock warm, so this window mostly decides how soon a
|
|
182
|
+
// crashed refresh is reclaimed rather than how long a slow one may run.
|
|
183
|
+
export async function acquireUsageRefreshLease(context, leaseMs = 60_000) {
|
|
184
|
+
return await tryLock(refreshLockPath(context), leaseMs, { baseUrl: context.baseUrl });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Writers wait briefly for each other; null means "skip this write" so a cache
|
|
188
|
+
// update never blocks a background refresh.
|
|
189
|
+
async function acquireWriteLock({ timeoutMs = 500, staleMs = 5_000 } = {}) {
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
do {
|
|
192
|
+
const release = await tryLock(WRITE_LOCK_PATH, staleMs);
|
|
193
|
+
if (release) return release;
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
195
|
+
} while (Date.now() < deadline);
|
|
196
|
+
return null;
|
|
115
197
|
}
|
|
@@ -13,9 +13,10 @@ export const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
|
13
13
|
export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
14
14
|
export const AGENT_CONFIG_PATH = join(AGENT_TOOLS_HOME, "config.jsonc");
|
|
15
15
|
export const DEBUG_PATH = join(AGENT_TOOLS_HOME, "logs", "usage-debug.log");
|
|
16
|
-
export const
|
|
17
|
-
export const
|
|
18
|
-
export const
|
|
16
|
+
export const CACHE_DIR = join(AGENT_TOOLS_HOME, "cache");
|
|
17
|
+
export const ROUTE_CACHE_PATH = join(CACHE_DIR, "usage-routes.json");
|
|
18
|
+
export const SNAPSHOT_PATH = join(CACHE_DIR, "usage-snapshot.json");
|
|
19
|
+
export const REFRESH_STATE_PATH = join(CACHE_DIR, "usage-refresh-state.json");
|
|
19
20
|
export const DEFAULT_USAGE_DAYS = 30;
|
|
20
21
|
export const MAX_USAGE_DAYS = 90;
|
|
21
22
|
export const DEFAULT_NEW_API_QUOTA_SCALE = 500000;
|
|
@@ -91,13 +92,13 @@ export async function usagePreset() {
|
|
|
91
92
|
return String(process.env.PROVIDER_USAGE_PRESET || config.preset || "auto").toLowerCase();
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
// Timing follows the caller's shape, so it is fixed rather than configurable:
|
|
96
|
+
// the hook never waits on the network (snapshot + background refresh), and
|
|
97
|
+
// every other caller either runs detached or is an explicit user request.
|
|
98
|
+
// Past this age a snapshot is too stale to show instead of nothing.
|
|
99
|
+
export const HOOK_SNAPSHOT_MAX_AGE_MS = 10 * 60_000;
|
|
100
|
+
// Minimum gap between gateway queries, however many hooks fire.
|
|
101
|
+
export const REFRESH_INTERVAL_MS = 60_000;
|
|
101
102
|
|
|
102
103
|
export async function newApiQuotaScale() {
|
|
103
104
|
const config = await agentConfig();
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
import { createContext, runInContext } from "node:vm";
|
|
5
5
|
import { debugLog } from "./config.mjs";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// No user waits on this request: the hook reads a snapshot and refreshes in a
|
|
8
|
+
// detached process, and skill/CLI queries are explicit. Custom routes can pass
|
|
9
|
+
// their own timeoutMs.
|
|
10
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
8
11
|
const SHIELD_USER_AGENT =
|
|
9
12
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
10
13
|
"(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
|
|
@@ -126,7 +129,7 @@ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
export async function requestJson(url, options = {}) {
|
|
129
|
-
const { key = "", headers = {}, name = "usage", timeoutMs =
|
|
132
|
+
const { key = "", headers = {}, name = "usage", timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = options;
|
|
130
133
|
let cookieHeader = "";
|
|
131
134
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
132
135
|
const controller = new AbortController();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kairyou/agent-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Reusable Agent Skills and installable integrations (statusline, provider usage, vision) for Codex, Claude Code, and opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"config.default.jsonc",
|
|
18
|
+
"docs/",
|
|
18
19
|
"dist/",
|
|
19
20
|
"integrations/",
|
|
20
21
|
"scripts/",
|
package/scripts/install.mjs
CHANGED
|
@@ -57,6 +57,15 @@ const INSTALL_ROOT =
|
|
|
57
57
|
process.env.AGENT_TOOLS_HOME || path.join(os.homedir(), ".agent-tools");
|
|
58
58
|
const META_KEY = "_agentTools";
|
|
59
59
|
const META_VERSION = 1;
|
|
60
|
+
// Stamped into install-state.json so a later install can tell which release
|
|
61
|
+
// wrote the current layout. Purely informational today.
|
|
62
|
+
const PACKAGE_VERSION = (() => {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version || "";
|
|
65
|
+
} catch {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
60
69
|
// Everything copied into ~/.agent-tools is built output from dist/ (see
|
|
61
70
|
// scripts/build.mjs); integrations/ holds the sources.
|
|
62
71
|
const SOURCE = {
|
|
@@ -395,14 +404,13 @@ function removeFile(file, dryRun) {
|
|
|
395
404
|
console.log(` removed ${file}`);
|
|
396
405
|
}
|
|
397
406
|
|
|
398
|
-
function usageEntry() {
|
|
407
|
+
function usageEntry({ silent = false } = {}) {
|
|
399
408
|
return {
|
|
400
409
|
hooks: [
|
|
401
410
|
{
|
|
402
411
|
type: "command",
|
|
403
|
-
command: nodeCmd(RUNTIME.codexUsageHook)
|
|
412
|
+
command: `${nodeCmd(RUNTIME.codexUsageHook)}${silent ? " --silent" : ""}`,
|
|
404
413
|
timeout: 5,
|
|
405
|
-
statusMessage: "Refreshing API usage",
|
|
406
414
|
},
|
|
407
415
|
],
|
|
408
416
|
};
|
|
@@ -433,7 +441,7 @@ function applyProviderUsage(cfg, { remove }) {
|
|
|
433
441
|
}
|
|
434
442
|
cfg.hooks[event] = cfg.hooks[event] || [];
|
|
435
443
|
cfg.hooks[event] = cfg.hooks[event].filter((entry) => !isOurProviderUsageEntry(entry));
|
|
436
|
-
cfg.hooks[event].push(usageEntry());
|
|
444
|
+
cfg.hooks[event].push(usageEntry({ silent: event === "UserPromptSubmit" }));
|
|
437
445
|
}
|
|
438
446
|
if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
|
|
439
447
|
}
|
|
@@ -631,6 +639,7 @@ function managedSkillStatus(dest, identity) {
|
|
|
631
639
|
|
|
632
640
|
function recordManagedSkill(dest, identity) {
|
|
633
641
|
const state = readInstallState();
|
|
642
|
+
state.packageVersion = PACKAGE_VERSION;
|
|
634
643
|
state.artifacts[skillManifestKey(dest)] = {
|
|
635
644
|
path: fwd(path.resolve(dest)),
|
|
636
645
|
...identity,
|
package/scripts/publish.mjs
CHANGED
|
@@ -41,8 +41,12 @@ if (typeof PACKAGE_NAME !== "string" || PACKAGE_NAME.trim() === "") {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function runNpm(args, { capture = false, allowFailure = false } = {}) {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// npm run sets npm_execpath. When invoked directly (`node scripts/...`), find
|
|
45
|
+
// the bundled npm-cli.js next to node — spawning npm.cmd fails on Node 22+.
|
|
46
|
+
// Version-manager layouts may not have it there, so fall back to PATH.
|
|
47
|
+
const bundledNpm = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
48
|
+
const npmExecPath = process.env.npm_execpath || (fs.existsSync(bundledNpm) ? bundledNpm : undefined);
|
|
49
|
+
const command = npmExecPath ? process.execPath : "npm";
|
|
46
50
|
const commandArgs = npmExecPath ? [npmExecPath, ...args] : args;
|
|
47
51
|
const result = spawnSync(command, commandArgs, {
|
|
48
52
|
cwd: ROOT,
|
package/scripts/release.mjs
CHANGED
|
@@ -78,12 +78,14 @@ function run(command, args, { label, onFailure } = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function runNpm(args) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
// npm run sets npm_execpath. When invoked directly (`node scripts/...`), find
|
|
82
|
+
// the bundled npm-cli.js next to node — spawning npm.cmd fails on Node 22+.
|
|
83
|
+
// Version-manager layouts may not have it there, so fall back to PATH.
|
|
84
|
+
const bundledNpm = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
85
|
+
const npmExecPath = process.env.npm_execpath || (fs.existsSync(bundledNpm) ? bundledNpm : undefined);
|
|
86
|
+
const command = npmExecPath ? process.execPath : "npm";
|
|
87
|
+
const commandArgs = npmExecPath ? [npmExecPath, ...args] : args;
|
|
88
|
+
run(command, commandArgs, { label: `npm ${args.join(" ")}` });
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
function printRecovery(commands) {
|