@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.
@@ -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();
@@ -4,7 +4,10 @@
4
4
  import { createContext, runInContext } from "node:vm";
5
5
  import { debugLog } from "./config.mjs";
6
6
 
7
- const REQUEST_TIMEOUT_MS = 5000;
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 = REQUEST_TIMEOUT_MS } = options;
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.9.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/",
@@ -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,
@@ -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
- const npmExecPath = process.env.npm_execpath;
45
- const command = npmExecPath ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm";
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,
@@ -78,12 +78,14 @@ function run(command, args, { label, onFailure } = {}) {
78
78
  }
79
79
 
80
80
  function runNpm(args) {
81
- const npmExecPath = process.env.npm_execpath;
82
- if (npmExecPath) {
83
- run(process.execPath, [npmExecPath, ...args], { label: `npm ${args.join(" ")}` });
84
- } else {
85
- run(process.platform === "win32" ? "npm.cmd" : "npm", args);
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) {