@kairyou/agent-tools 0.8.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.
@@ -0,0 +1,47 @@
1
+ # 自定义网关路由
2
+
3
+ [Provider usage](../../README.zh-CN.md#provider-usage) 的高级指南: 为内置 preset 覆盖不到的中转(比如 cookie 认证的网关)编写自己的用量探测, 无需修改包内代码.
4
+
5
+ ## 声明路由
6
+
7
+ 编写路由模块, 并在 `providerUsage.routes` 里声明(相对 `~/.agent-tools` 解析). 声明的路由优先探测; `"preset"` 填路由 id 可直接选中.
8
+
9
+ ```jsonc
10
+ {
11
+ "providerUsage": {
12
+ "routes": [
13
+ "custom/my-gateway.mjs",
14
+ "custom/another-gateway.mjs"
15
+ ],
16
+ "myGateway": { "username": "me", "password": "..." }
17
+ }
18
+ }
19
+ ```
20
+
21
+ ## 路由模块 API
22
+
23
+ ```js
24
+ // ~/.agent-tools/custom/my-gateway.mjs
25
+ export const meta = { id: "my-gateway" }; // 可选; id 默认取文件名
26
+
27
+ export async function run(context, { requestJson, agentConfig }) {
28
+ // context: { baseUrl, key, providerName, provider, label }
29
+ const { myGateway = {} } = await agentConfig(); // providerUsage 对象, 自定义键随意加
30
+
31
+ const login = await fetch(`${context.baseUrl}/api/user/login`, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify({ username: myGateway.username, password: myGateway.password }),
35
+ });
36
+ const session = await login.json();
37
+
38
+ // requestJson 会解析 JSON, 并在非 2xx 响应时抛错; 需要时可在这里传入
39
+ // authorization、cookie 等自定义认证 header.
40
+ const me = await requestJson(`${context.baseUrl}/api/user/self`, {
41
+ headers: { authorization: `Bearer ${session?.data?.accessToken}` },
42
+ });
43
+ return { text: `balance ¥${me?.data?.balance}` };
44
+ }
45
+ ```
46
+
47
+ `text` 是自由字符串; 成功返回 `{ text }`, 抛错则回落到下一条路由. 开启 `providerUsage.debug` 后, 探测失败会记录到 `~/.agent-tools/logs/usage-debug.log`.
@@ -0,0 +1,20 @@
1
+ # 仓库结构
2
+
3
+ ```text
4
+ agent-tools/
5
+ ├── .claude-plugin/ # Claude Code/plugin 生态的 manifest.
6
+ ├── .codex-plugin/ # Codex plugin manifest.
7
+ ├── integrations/ # 可安装的 capability, 一个一目录.
8
+ │ ├── statusline/ # Agent 状态栏: 分支, 模型, 用量.
9
+ │ ├── usage/ # Provider 余额/额度显示.
10
+ │ └── vision/ # 跨模型识图.
11
+ ├── skills/ # 可复用的 Agent Skills.
12
+ │ ├── workflow/ # 工作流类 skills.
13
+ │ │ ├── at-commit/ # 生成 Conventional Commits message.
14
+ │ │ ├── at-review/ # 审查改动中的 bug 与回归风险.
15
+ │ │ └── at-simplify/ # 减少改动中的冗余和复杂度.
16
+ │ └── integrations/ # 对接外部系统的 skills.
17
+ │ └── at-zentao/ # 禅道 bug/task 修复工作流.
18
+ ├── docs/ # 高级指南和贡献者参考.
19
+ └── scripts/ # 安装, 同步, 校验和仓库维护脚本.
20
+ ```
@@ -16,6 +16,7 @@ const LOG_PATH = path.join(AGENT_TOOLS_HOME, "logs", "usage-hook.log");
16
16
  const TIMEOUT_MS = Number(process.env.AGENT_TOOLS_USAGE_HOOK_TIMEOUT_MS || 4500);
17
17
  const MAX_LOG_BYTES = Number(process.env.AGENT_TOOLS_USAGE_HOOK_LOG_BYTES || 256 * 1024);
18
18
  const KEEP_LOG_BYTES = 128 * 1024;
19
+ const SILENT = process.argv.includes("--silent");
19
20
 
20
21
  function hookOut(message) {
21
22
  const payload = { continue: true };
@@ -64,6 +65,10 @@ function failureMessage() {
64
65
  return `API usage hook failed; see ${LOG_PATH.replace(/\\/g, "/")}`;
65
66
  }
66
67
 
68
+ function hookFailureOut() {
69
+ hookOut(SILENT ? "" : failureMessage());
70
+ }
71
+
67
72
  function parseHookJson(stdout) {
68
73
  const text = stdout.trim();
69
74
  if (!text) return { continue: true };
@@ -73,17 +78,21 @@ function parseHookJson(stdout) {
73
78
  async function runUsageScript() {
74
79
  if (!fs.existsSync(USAGE_SCRIPT)) {
75
80
  logFailure({ reason: "missing usage script", usageScript: USAGE_SCRIPT });
76
- hookOut(failureMessage());
81
+ hookFailureOut();
77
82
  return;
78
83
  }
79
84
 
80
85
  const result = await new Promise((resolve) => {
81
- const child = spawn(process.execPath, [USAGE_SCRIPT, "hook", "--agent", "codex"], {
82
- cwd: process.cwd(),
83
- env: process.env,
84
- stdio: ["ignore", "pipe", "pipe"],
85
- windowsHide: true,
86
- });
86
+ const child = spawn(
87
+ process.execPath,
88
+ [USAGE_SCRIPT, "hook", "--agent", "codex", ...(SILENT ? ["--silent"] : [])],
89
+ {
90
+ cwd: process.cwd(),
91
+ env: process.env,
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ windowsHide: true,
94
+ }
95
+ );
87
96
  let stdout = "";
88
97
  let stderr = "";
89
98
  let settled = false;
@@ -126,7 +135,7 @@ async function runUsageScript() {
126
135
  node: process.version,
127
136
  platform: `${process.platform} ${os.release()}`,
128
137
  });
129
- hookOut(failureMessage());
138
+ hookFailureOut();
130
139
  return;
131
140
  }
132
141
 
@@ -141,7 +150,7 @@ async function runUsageScript() {
141
150
  stderr: preview(result.stderr),
142
151
  usageScript: USAGE_SCRIPT,
143
152
  });
144
- hookOut(failureMessage());
153
+ hookFailureOut();
145
154
  }
146
155
  }
147
156
 
@@ -153,5 +162,5 @@ try {
153
162
  error: error?.stack || error?.message || String(error),
154
163
  usageScript: USAGE_SCRIPT,
155
164
  });
156
- hookOut(failureMessage());
165
+ hookFailureOut();
157
166
  }
@@ -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 { debugLog, usagePreset, snapshotTtlMs } from "./lib/config.mjs";
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
- return await queryAgentProviderUsage(agent);
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 readUsageSnapshot(context);
121
- const age = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
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" || mode === "print-or-refresh") {
132
- const result = await refresh(cli.agent);
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
- // Hook mode fires on every prompt; serve a fresh snapshot when possible.
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 { dirname } from "node:path";
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 ROUTE_CACHE_VERSION = 1;
16
- const SNAPSHOT_VERSION = 1;
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
- export async function readRouteCache() {
20
+ // Every cache file is { version, <field>: { [gatewayKey]: entry } }.
21
+ async function readJsonCache(path, field) {
20
22
  try {
21
- const raw = await readTextIfExists(ROUTE_CACHE_PATH);
22
- if (!raw.trim()) return { version: ROUTE_CACHE_VERSION, routes: {} };
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: ROUTE_CACHE_VERSION,
26
- routes: parsed?.routes && typeof parsed.routes === "object" ? parsed.routes : {},
26
+ version: CACHE_VERSION,
27
+ [field]: entries && typeof entries === "object" ? entries : {},
27
28
  };
28
29
  } catch {
29
- return { version: ROUTE_CACHE_VERSION, routes: {} };
30
+ return { version: CACHE_VERSION, [field]: {} };
30
31
  }
31
32
  }
32
33
 
33
- export async function rememberUsageRoute(context, route, result) {
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 readRouteCache();
36
- const key = usageRouteCacheKey(context.baseUrl);
37
- cache.routes[key] = {
38
- route: route.id,
39
- path: route.path,
40
- source: result.source,
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: "route-cache", error: error.message });
54
+ await debugLog({ source, error: error.message });
55
+ } finally {
56
+ await release();
47
57
  }
48
58
  }
49
59
 
50
- async function readSnapshotCache() {
51
- try {
52
- const raw = await readTextIfExists(SNAPSHOT_PATH);
53
- if (!raw.trim()) return { version: SNAPSHOT_VERSION, items: {} };
54
- const parsed = JSON.parse(raw);
55
- return {
56
- version: SNAPSHOT_VERSION,
57
- items: parsed?.items && typeof parsed.items === "object" ? parsed.items : {},
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
- } catch {
60
- return { version: SNAPSHOT_VERSION, items: {} };
61
- }
72
+ });
62
73
  }
63
74
 
64
75
  export async function readUsageSnapshot(context) {
65
- const cache = await readSnapshotCache();
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
- try {
72
- const cache = await readSnapshotCache();
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
- await mkdir(dirname(SNAPSHOT_PATH), { recursive: true });
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
- try {
103
- const state = await readRefreshState();
93
+ await updateJsonCache(REFRESH_STATE_PATH, "items", "refresh-state", (items) => {
104
94
  const key = usageRouteCacheKey(context.baseUrl);
105
- state.items[key] = {
106
- ...(state.items[key] || {}),
107
- ...patch,
108
- baseUrl: context.baseUrl,
109
- };
110
- await mkdir(dirname(REFRESH_STATE_PATH), { recursive: true });
111
- await writeFile(REFRESH_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
112
- } catch (error) {
113
- await debugLog({ source: "refresh-state", error: error.message });
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 ROUTE_CACHE_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-routes.json");
17
- export const SNAPSHOT_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-snapshot.json");
18
- export const REFRESH_STATE_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-refresh-state.json");
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
- // Passive callers (the codex hook fires per prompt; several sessions may run
95
- // at once) reuse a fresh snapshot instead of hitting the gateway every time.
96
- // Same knob the statusline uses; 0 disables.
97
- export function snapshotTtlMs() {
98
- const raw = Number(process.env.AGENT_TOOLS_USAGE_SNAPSHOT_TTL_MS);
99
- return Number.isFinite(raw) && raw >= 0 ? raw : 60_000;
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();