From c2dd89cff69058726a9df25b0d851fb2a5d8428b Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 18:00:29 -0700 Subject: [PATCH 1/6] fix(staleness): bound npm registry fetch with a timeout fetchNpmVersion had no timeout/AbortSignal, so an unreachable or slow npm registry could block `intent stale` indefinitely. Bound it to 5s; timeout/error still falls through to the existing null fallback. --- packages/intent/src/staleness/check.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/intent/src/staleness/check.ts b/packages/intent/src/staleness/check.ts index ec3191b..561b0b3 100644 --- a/packages/intent/src/staleness/check.ts +++ b/packages/intent/src/staleness/check.ts @@ -90,10 +90,13 @@ function readLocalVersion(packageDir: string): string | null { } } +const NPM_REGISTRY_FETCH_TIMEOUT_MS = 5_000 + async function fetchNpmVersion(packageName: string): Promise { try { const res = await fetch( `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + { signal: AbortSignal.timeout(NPM_REGISTRY_FETCH_TIMEOUT_MS) }, ) if (!res.ok) return null const data = (await res.json()) as Record From 975275bdd7412184de4593a0fb82f43e35f71dd5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 18:02:54 -0700 Subject: [PATCH 2/6] fix(shared): bound global package-manager detection with a timeout --- packages/intent/src/shared/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index 2bc8d30..ed4b5ad 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -232,6 +232,8 @@ export function listNestedNodeModulesPackageDirs( return packageDirs } +const GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS = 5_000 + export function detectGlobalNodeModules(packageManager: string): { path: string | null source?: string @@ -267,6 +269,7 @@ export function detectGlobalNodeModules(packageManager: string): { const output = execFileSync(candidate.command, candidate.args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + timeout: GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS, }).trim() if (!output) continue From 17fb0352c21b18b8f3a6709fbb40648891f71aa8 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 18:05:08 -0700 Subject: [PATCH 3/6] perf(setup): early-exit skill-file check in getWorkspaceInfo --- .../intent/src/setup/workspace-patterns.ts | 4 ++-- packages/intent/src/shared/utils.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/intent/src/setup/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts index 1285775..08005ee 100644 --- a/packages/intent/src/setup/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from '../shared/utils.js' +import { hasAnySkillFile } from '../shared/utils.js' import type { ParseError } from 'jsonc-parser' function normalizeWorkspacePattern(pattern: string): string { @@ -227,7 +227,7 @@ export function getWorkspaceInfo(root: string): WorkspaceInfo | null { const packageDirs = readWorkspacePackageDirs(root) ?? [] const packageDirsWithSkills = packageDirs.filter((dir) => { const skillsDir = join(dir, 'skills') - return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0 + return existsSync(skillsDir) && hasAnySkillFile(skillsDir) }) const info = { root, diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index ed4b5ad..f3d6930 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -114,6 +114,30 @@ function collectSkillFiles( } } +/** + * Like `findSkillFiles`, but stops at the first SKILL.md found instead of + * enumerating the whole tree. + */ +export function hasAnySkillFile(dir: string, fs: ReadFs = nodeReadFs): boolean { + let entries: Array> + try { + entries = fs.readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) + } catch { + return false + } + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + if (hasAnySkillFile(fullPath, fs)) return true + } else if (entry.name === 'SKILL.md') { + return true + } + } + + return false +} + /** * Read dependencies and peerDependencies (and optionally devDependencies) from * a parsed package.json object. From ec6475b9819ea79b9c04e63bc1039d88dfd9f295 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 18:07:35 -0700 Subject: [PATCH 4/6] perf(bench): add process-level cold-start benchmark --- benchmarks/intent/startup.bench.ts | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 benchmarks/intent/startup.bench.ts diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts new file mode 100644 index 0000000..472f740 --- /dev/null +++ b/benchmarks/intent/startup.bench.ts @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { bench, describe } from 'vitest' + +const cliPath = fileURLToPath( + new URL('../../packages/intent/dist/cli.mjs', import.meta.url), +) + +const coldStartBenchOptions = { + warmupIterations: 20, + time: 3_000, +} + +function runNode(args: Array): void { + const result = spawnSync(process.execPath, args, { stdio: 'ignore' }) + if (result.status !== 0) { + throw new Error( + `spawn ${[process.execPath, ...args].join(' ')} exited with code ${result.status}`, + ) + } +} + +describe('cold start', () => { + bench( + 'empty node process (baseline)', + () => { + runNode(['-e', '']) + }, + coldStartBenchOptions, + ) + + bench( + 'intent --help', + () => { + runNode([cliPath, '--help']) + }, + coldStartBenchOptions, + ) +}) From 102d81b4837cccc1480697d9de84dc97133af5ea Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 18:10:15 -0700 Subject: [PATCH 5/6] perf: fix potential hangs on slow or large environments --- .changeset/nine-pigs-laugh.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/nine-pigs-laugh.md diff --git a/.changeset/nine-pigs-laugh.md b/.changeset/nine-pigs-laugh.md new file mode 100644 index 0000000..8b1cc55 --- /dev/null +++ b/.changeset/nine-pigs-laugh.md @@ -0,0 +1,9 @@ +--- +'@tanstack/intent': patch +--- + +Fix potential hangs on slow or large environments: + +- Bound the npm registry staleness check with a request timeout so `intent list` and other staleness checks can't hang indefinitely on a slow or unreachable registry. +- Bound global package-manager detection with a command timeout so it can't hang indefinitely when the environment's global `node_modules` is slow to resolve. +- Avoid enumerating a workspace package's entire skill tree just to check whether it has any skills, reducing filesystem work in large monorepos. From 0e9596140f8ce6ef83d1a5bb9e18c343734e40dd Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 18:46:02 -0700 Subject: [PATCH 6/6] add timeout to node process execution in cold start benchmark --- benchmarks/intent/startup.bench.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts index 472f740..cf0159d 100644 --- a/benchmarks/intent/startup.bench.ts +++ b/benchmarks/intent/startup.bench.ts @@ -12,7 +12,10 @@ const coldStartBenchOptions = { } function runNode(args: Array): void { - const result = spawnSync(process.execPath, args, { stdio: 'ignore' }) + const result = spawnSync(process.execPath, args, { + stdio: 'ignore', + timeout: 10_000, + }) if (result.status !== 0) { throw new Error( `spawn ${[process.execPath, ...args].join(' ')} exited with code ${result.status}`,