{"record":{"id":"4e39ca5f45b764bc","repo":"can1357/oh-my-pi","slug":"artifact-id-resolved-to-a-directory-not-a-file","errorCode":null,"errorMessage":"Artifact ${id} resolved to a directory, not a file","messagePattern":"Artifact (.+?) resolved to a directory, not a file","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/internal-urls/artifact-protocol.ts","lineNumber":93,"sourceCode":"\t\tfor (const f of files) {\n\t\t\tconst m = f.match(/^(\\d+)\\./);\n\t\t\tif (m) availableIds.add(m[1]);\n\t\t}\n\t}\n\n\tif (!anyDirExists) {\n\t\tthrow new Error(\"No artifacts directory found\");\n\t}\n\n\tif (!foundPath) {\n\t\tconst sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));\n\t\tconst availableStr = sorted.length > 0 ? sorted.join(\", \") : \"none\";\n\t\tthrow new Error(`Artifact ${id} not found. Available: ${availableStr}`);\n\t}\n\n\tconst stat = await Bun.file(foundPath).stat();\n\tif (stat.isDirectory()) {\n\t\tthrow new Error(`Artifact ${id} resolved to a directory, not a file`);\n\t}\n\treturn { id, path: foundPath, size: stat.size };\n}\n\nexport class ArtifactProtocolHandler implements ProtocolHandler {\n\treadonly scheme = \"artifact\";\n\treadonly immutable = true;\n\n\tasync resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {\n\t\tconst artifact = await resolveArtifactFile(url, context);\n\n\t\t// Path-only callers (search/grep, bash URL expansion) never touch the\n\t\t// artifact bytes. Return the resource shape so those flows keep working\n\t\t// on artifacts of any size — only content materialization is gated.\n\t\tif (context?.pathOnly) {\n\t\t\treturn {\n\t\t\t\turl: url.href,\n\t\t\t\tcontent: \"\",","sourceCodeStart":75,"sourceCodeEnd":111,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/internal-urls/artifact-protocol.ts#L75-L111","documentation":"After resolving an artifact id to a path, resolveArtifactFile stats the file and rejects directories. Artifact ids are expected to map to individual files; a directory match means the id resolution matched something the protocol cannot serve as content.","triggerScenarios":"An artifact id resolves to a directory path inside the artifacts directory — typically when the on-disk naming scheme contains a directory whose name collides with the id pattern, or a tool wrote a directory where a file was expected.","commonSituations":"Custom tooling wrote nested output (e.g. extracted archives) under the artifacts dir; an id was reused for a directory output; filesystem layout changed between tool versions.","solutions":["Point the artifact:// URL at a file id, not the directory","Inspect the artifacts directory and remove or rename the directory that collides with an artifact id","If a tool produced the directory, re-run the tool so it emits a file artifact"],"exampleFix":"// before\nawait resolve('artifact://bundle')      // 'bundle' is a directory\n// after\nawait resolve('artifact://bundle/index.html') // reference a file artifact","handlingStrategy":"validation","validationCode":"import { stat } from 'node:fs/promises'\nconst st = await stat(path)\nif (st.isDirectory()) throw new Error(`${path} is a directory; pick a file artifact`)","typeGuard":null,"tryCatchPattern":"try {\n  return await resolve(`artifact://${id}`)\n} catch (err) {\n  if (String(err).includes('resolved to a directory')) {\n    logger.warn('artifact id maps to a directory, skipping', { id })\n    return null\n  }\n  throw err\n}","preventionTips":["Have tools emit files, not directories, as artifacts","Avoid naming directories after artifact-id patterns under the artifacts dir","Verify the resolved path is a file when scanning artifacts manually"],"tags":["artifact","invalid-target","filesystem"],"backgroundTag":"expected-file-got-directory","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}