From be0f37997f130341a68ce187372f4d305b6ae59e Mon Sep 17 00:00:00 2001 From: Julio Reyes Date: Fri, 31 Jul 2026 09:08:35 -0300 Subject: [PATCH 1/4] fix(cursor): discover cursor skills per project workspace --- .../src/features/threads/ThreadComposer.tsx | 9 +- .../features/threads/ThreadDetailScreen.tsx | 16 +- .../threads/new-task-flow-provider.tsx | 13 +- apps/mobile/src/state/queries.ts | 32 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/provider/Drivers/CursorDriver.ts | 13 + .../src/provider/Drivers/CursorSkills.test.ts | 529 ++++++++++++++++++ .../src/provider/Drivers/CursorSkills.ts | 324 +++++++++++ .../ProviderRegistry.snapshotSource.test.ts | 56 ++ .../src/provider/Layers/ProviderRegistry.ts | 21 +- apps/server/src/provider/ProviderDriver.ts | 19 + .../provider/ProviderSkillInventory.test.ts | 255 +++++++++ .../src/provider/ProviderSkillInventory.ts | 116 ++++ apps/server/src/server.test.ts | 37 +- apps/server/src/ws.ts | 19 + apps/web/src/components/ChatView.tsx | 13 +- apps/web/src/components/chat/ChatComposer.tsx | 51 +- .../components/chat/MessagesTimeline.test.tsx | 13 + apps/web/src/state/queries.ts | 29 + docs/internals/providers.md | 30 + packages/client-runtime/package.json | 4 + .../src/state/providerSkillInventory.test.ts | 195 +++++++ .../src/state/providerSkillInventory.ts | 115 ++++ packages/client-runtime/src/state/server.ts | 12 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.test.ts | 28 +- packages/contracts/src/server.ts | 59 ++ 27 files changed, 1970 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/provider/Drivers/CursorSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/CursorSkills.ts create mode 100644 apps/server/src/provider/Layers/ProviderRegistry.snapshotSource.test.ts create mode 100644 apps/server/src/provider/ProviderSkillInventory.test.ts create mode 100644 apps/server/src/provider/ProviderSkillInventory.ts create mode 100644 packages/client-runtime/src/state/providerSkillInventory.test.ts create mode 100644 packages/client-runtime/src/state/providerSkillInventory.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc45cba4260..47a6e296c9f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,6 +7,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProviderSkill, } from "@t3tools/contracts"; import { detectComposerTrigger, @@ -99,6 +100,7 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; + readonly providerSkills: ReadonlyArray; readonly queueCount: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; @@ -365,7 +367,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, query: composerTrigger?.kind === "path" ? composerTrigger.query : null, }); - const composerMenuItems: ComposerCommandItem[] = useMemo(() => { if (!composerTrigger) return []; @@ -412,7 +413,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = props.providerSkills.filter((s) => s.enabled); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); @@ -509,7 +510,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, props.providerSkills, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -785,7 +786,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={props.providerSkills} selection={composerSelection} onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..70fd62945a1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -26,6 +26,7 @@ import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { useProviderSkills } from "../../state/queries"; import type { PendingApproval, PendingUserInput, @@ -225,12 +226,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo( + const selectedProviderStatus = useMemo( () => - props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) - ?.skills ?? [], + props.serverConfig?.providers.find( + (provider) => provider.instanceId === selectedInstanceId, + ) ?? null, [props.serverConfig, selectedInstanceId], ); + const selectedProviderSkills = useProviderSkills({ + activeEnvironmentId: props.environmentId, + provider: selectedProviderStatus, + isServerThread: true, + threadId: props.selectedThread.id, + projectId: props.selectedThread.projectId, + }); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; @@ -428,6 +437,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} + providerSkills={selectedProviderSkills} queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 8d4ce7a7fed..b788cdd7472 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -41,7 +41,7 @@ import { updateComposerDraftSettings, useComposerDraft, } from "../../state/use-composer-drafts"; -import { useBranches } from "../../state/queries"; +import { useBranches, useProviderSkills } from "../../state/queries"; import { flattenQueuedThreadMessages, threadOutboxManager, @@ -406,13 +406,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = useMemo( + const selectedProviderStatus = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? [], + ) ?? null, [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); + const selectedProviderSkills = useProviderSkills({ + activeEnvironmentId: selectedEnvironmentId, + provider: selectedProviderStatus, + isServerThread: false, + threadId: null, + projectId: selectedProject?.id ?? null, + }); const setSelectedModelKey = useCallback( (key: string | null) => { if (!key || !selectedProjectDraftKey) { diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index b02b190db25..cdba057d281 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,4 +1,15 @@ -import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationThread, + ServerProviderSkill, + ThreadId, +} from "@t3tools/contracts"; +import { + type ProviderSkillInventoryContext, + resolveProviderSkillInventoryRequest, + resolveProviderSkillInventoryTarget, + selectProviderSkills, +} from "@t3tools/client-runtime/state/provider-skill-inventory"; import { createThreadSearchResultsAtomFamily, makeThreadSearchKey, @@ -12,6 +23,7 @@ import { useEffect, useMemo, useState } from "react"; import { orchestrationEnvironment } from "./orchestration"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; +import { serverEnvironment } from "./server"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; import { @@ -125,6 +137,24 @@ export function useBranches(input: { ); } +/** + * Skills for a composer or message feed. Snapshot-mode providers never hit the + * network; project-mode providers issue one request per + * (environment, scope, instance). + */ +export function useProviderSkills( + context: ProviderSkillInventoryContext, +): ReadonlyArray { + const target = resolveProviderSkillInventoryTarget(context); + const request = resolveProviderSkillInventoryRequest(target); + const result = useEnvironmentQuery( + request === null ? null : serverEnvironment.skillInventory(request), + ); + const provider = target.provider; + const inventory = result.data; + return useMemo(() => selectProviderSkills({ provider, inventory }), [provider, inventory]); +} + export function useComposerPathSearch(target: ComposerPathSearchTarget) { const normalizedTarget = useMemo( () => ({ diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index fb753b9aa4b..33f10ef88a2 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.providersSkillInventory]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb..4b11a1fe580 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -37,7 +37,9 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, + type ProviderSkillInventory, } from "../ProviderDriver.ts"; +import { discoverCursorSkills } from "./CursorSkills.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { @@ -173,6 +175,16 @@ export const CursorDriver: ProviderDriver = { ), ); + // Closes over the same merged environment the ACP child is spawned + // with, so discovery reads the home roots `cursor-agent` would read. + const skillInventory: ProviderSkillInventory = { + list: ({ cwd }) => + discoverCursorSkills(cwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + }; + return { instanceId, driverKind: DRIVER_KIND, @@ -183,6 +195,7 @@ export const CursorDriver: ProviderDriver = { snapshot, adapter, textGeneration, + skillInventory, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts new file mode 100644 index 00000000000..feb10681501 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -0,0 +1,529 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { + discoverCursorSkills, + MAX_DIRECTORIES_PER_ROOT, + MAX_ENTRIES_PER_ROOT, + resolveCursorHomeDirectory, +} from "./CursorSkills.ts"; + +const writeSkill = Effect.fn(function* (skillDirectory: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(skillDirectory, { recursive: true }); + yield* fs.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); +}); + +const frontmatter = (name: string, description: string) => + ["---", `name: ${name}`, `description: ${description}`, "---", "", "# Body"].join("\n"); + +/** + * Fixture home/workspace pair plus an environment whose `HOME` points at the + * fixture rather than the real one, so tests never read the developer's own + * skills. + */ +const makeFixture = Effect.fn(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.makeDirectory(workspace, { recursive: true }); + return { + tempDir, + home, + workspace, + environment: { HOME: home, USERPROFILE: home } satisfies NodeJS.ProcessEnv, + userCursor: path.join(home, ".cursor", "skills"), + userAgents: path.join(home, ".agents", "skills"), + projectCursor: path.join(workspace, ".cursor", "skills"), + projectAgents: path.join(workspace, ".agents", "skills"), + }; +}); + +it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { + it.effect("merges all four roots with project and .cursor winning collisions", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + + yield* writeSkill( + path.join(fixture.userAgents, "user-agents-only"), + frontmatter("user-agents-only", "From the user .agents root."), + ); + yield* writeSkill( + path.join(fixture.userCursor, "user-cursor-only"), + frontmatter("user-cursor-only", "From the user .cursor root."), + ); + // Same name in every root: the highest-precedence one must win, and the + // row must appear exactly once. + yield* writeSkill( + path.join(fixture.userAgents, "everywhere"), + frontmatter("everywhere", "user agents"), + ); + yield* writeSkill( + path.join(fixture.userCursor, "everywhere"), + frontmatter("everywhere", "user cursor"), + ); + yield* writeSkill( + path.join(fixture.projectAgents, "everywhere"), + frontmatter("everywhere", "project agents"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "everywhere"), + frontmatter("everywhere", "project cursor"), + ); + // Same scope, different roots: `.cursor` beats `.agents`. + yield* writeSkill( + path.join(fixture.projectAgents, "same-scope"), + frontmatter("same-scope", "project agents"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "same-scope"), + frontmatter("same-scope", "project cursor"), + ); + // Organizational category directory nested inside a root. + yield* writeSkill( + path.join(fixture.projectCursor, "shipping", "land-it"), + frontmatter("land-it", "Nested under a category directory."), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.scope, skill.description]), + [ + ["everywhere", "project", "project cursor"], + ["land-it", "project", "Nested under a category directory."], + ["same-scope", "project", "project cursor"], + ["user-agents-only", "user", "From the user .agents root."], + ["user-cursor-only", "user", "From the user .cursor root."], + ], + ); + assert.equal( + skills.find((skill) => skill.name === "land-it")?.path, + path.join(fixture.projectCursor, "shipping", "land-it", "SKILL.md"), + ); + assert.isTrue(skills.every((skill) => skill.enabled)); + }), + ); + + it.effect("resolves inventory per working directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const otherWorkspace = path.join(fixture.tempDir, "other-workspace"); + yield* fs.makeDirectory(otherWorkspace, { recursive: true }); + + yield* writeSkill(path.join(fixture.userCursor, "shared"), frontmatter("shared", "user")); + yield* writeSkill(path.join(fixture.projectCursor, "only-a"), frontmatter("only-a", "A")); + yield* writeSkill( + path.join(otherWorkspace, ".cursor", "skills", "shared"), + frontmatter("shared", "overridden by B"), + ); + + const first = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + const second = yield* discoverCursorSkills(otherWorkspace, fixture.environment); + + assert.deepEqual( + first.map((skill) => [skill.name, skill.description]), + [ + ["only-a", "A"], + ["shared", "user"], + ], + ); + assert.deepEqual( + second.map((skill) => [skill.name, skill.description]), + [["shared", "overridden by B"]], + ); + }), + ); + + /** + * `cursor-agent` 2026.07.20-8cc9c0b loads every one of these. It names a + * skill after its directory and derives a missing description from the + * body's first heading, so none of them may be dropped. + */ + it.effect("keeps skills Cursor itself loads despite loose frontmatter", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + + yield* writeSkill( + path.join(fixture.projectCursor, "mismatched-dir"), + frontmatter("renamed-skill", "Frontmatter name disagrees with the directory."), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "no-desc"), + ["---", "name: no-desc", "---", "", "# No description probe", "", "Body."].join("\n"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "no-frontmatter"), + ["# No frontmatter probe", "", "Body."].join("\n"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "Weird_Name"), + frontmatter("Weird_Name", "Non-conforming characters still load."), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "unterminated"), + ["---", "name: unterminated", "description: Never closed.", "", "# Fallback"].join("\n"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "not-a-mapping"), + ["---", "- just", "- a list", "---", "", "# List frontmatter"].join("\n"), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [ + ["mismatched-dir", "Frontmatter name disagrees with the directory."], + ["no-desc", "No description probe"], + ["no-frontmatter", "No frontmatter probe"], + // An unterminated `---` never opens frontmatter, so the first + // heading is whatever the body starts with. + ["not-a-mapping", "List frontmatter"], + ["unterminated", "Fallback"], + ["Weird_Name", "Non-conforming characters still load."], + ], + ); + }), + ); + + it.effect("parses a BOM and CRLF file, and ignores a non-delimiter --- line", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + + yield* writeSkill( + path.join(fixture.projectCursor, "bom-crlf"), + `\ufeff${["---", "name: bom-crlf", "description: Handles BOM and CRLF.", "---", "", "# Body"].join("\r\n")}`, + ); + yield* writeSkill( + path.join(fixture.projectCursor, "not-a-delimiter"), + ["--- not a delimiter", "name: ignored", "---", "", "# Real heading"].join("\n"), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [ + ["bom-crlf", "Handles BOM and CRLF."], + // Frontmatter never opened, so `name: ignored` is body text and the + // description comes from the heading. + ["not-a-delimiter", "Real heading"], + ], + ); + }), + ); + + it.effect("skips only what it cannot read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + + yield* writeSkill(path.join(fixture.projectCursor, "healthy"), frontmatter("healthy", "OK.")); + // A directory with no SKILL.md is a category directory, not a skill. + yield* fs.makeDirectory(path.join(fixture.projectCursor, "empty-category"), { + recursive: true, + }); + const blocked = path.join(fixture.projectCursor, "blocked"); + yield* writeSkill(path.join(blocked, "hidden"), frontmatter("hidden", "Unreachable.")); + yield* fs.chmod(blocked, 0o000); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment).pipe( + Effect.ensuring(fs.chmod(blocked, 0o700).pipe(Effect.ignore)), + ); + + assert.deepEqual( + skills.map((skill) => skill.name), + typeof process.getuid === "function" && process.getuid() === 0 + ? ["healthy", "hidden"] + : ["healthy"], + ); + }), + ); + + it.effect("reads the merged environment's home, not the process home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const otherHome = path.join(fixture.tempDir, "other-home"); + + yield* writeSkill( + path.join(fixture.userCursor, "fixture-home"), + frontmatter("fixture-home", "Under the fixture HOME."), + ); + yield* writeSkill( + path.join(otherHome, ".cursor", "skills", "other-home"), + frontmatter("other-home", "Under the overridden HOME."), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, { HOME: otherHome }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["other-home"], + ); + }), + ); + + it.effect("terminates on a symlinked directory cycle and sorts deterministically", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + + yield* writeSkill(path.join(fixture.projectCursor, "beta"), frontmatter("beta", "Beta.")); + yield* writeSkill(path.join(fixture.projectCursor, "alpha"), frontmatter("alpha", "Alpha.")); + const loopCreated = yield* fs + .symlink(fixture.projectCursor, path.join(fixture.projectCursor, "loop")) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.isTrue(loopCreated, "platform does not support symlinks"); + assert.deepEqual( + skills.map((skill) => skill.name), + ["alpha", "beta"], + ); + }), + ); + + it.effect("follows a skill symlink whose canonical target stays inside the root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const target = path.join(fixture.projectCursor, "z-target"); + + yield* writeSkill(target, frontmatter("z-target", "Reached through an in-root symlink.")); + yield* fs.symlink(target, path.join(fixture.projectCursor, "a-linked")); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [["a-linked", "Reached through an in-root symlink."]], + ); + }), + ); + + it.effect("does not follow skill roots or nested symlinks outside their scope boundary", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const outside = path.join(fixture.tempDir, "outside"); + const escapedRoot = path.join(outside, "root-skills"); + const escapedNestedSkill = path.join(outside, "nested-skill"); + + yield* writeSkill( + path.join(escapedRoot, "through-root"), + frontmatter("through-root", "Outside through the root symlink."), + ); + yield* writeSkill( + escapedNestedSkill, + frontmatter("through-child", "Outside through a nested symlink."), + ); + yield* fs.makeDirectory(path.dirname(fixture.projectAgents), { recursive: true }); + yield* fs.makeDirectory(fixture.projectCursor, { recursive: true }); + yield* fs.symlink(escapedRoot, fixture.projectAgents); + yield* fs.symlink(escapedNestedSkill, path.join(fixture.projectCursor, "escaped-child")); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.isEmpty(skills); + }), + ); + + it.effect("bounds directories visited even when none contain skills", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const directoryNames = Array.from( + { length: MAX_DIRECTORIES_PER_ROOT + 50 }, + (_, index) => `category-${String(index).padStart(4, "0")}`, + ); + let emptyDirectoriesRead = 0; + + yield* fs.makeDirectory(fixture.projectCursor, { recursive: true }); + const resolvedProjectCursor = yield* fs.realPath(fixture.projectCursor); + const directoryInfo = yield* fs.stat(fixture.projectCursor); + const instrumented = Layer.succeed( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fs, + readDirectory: (directory, options) => { + const current = String(directory); + if (current === resolvedProjectCursor) { + return Effect.succeed(directoryNames); + } + if (current.startsWith(`${resolvedProjectCursor}${path.sep}`)) { + emptyDirectoriesRead += 1; + return Effect.succeed([]); + } + return fs.readDirectory(directory, options); + }, + realPath: (filePath) => { + const current = String(filePath); + if (current.startsWith(`${fixture.projectCursor}${path.sep}`)) { + return Effect.succeed(current.replace(fixture.projectCursor, resolvedProjectCursor)); + } + return current.startsWith(`${resolvedProjectCursor}${path.sep}`) + ? Effect.succeed(current) + : fs.realPath(filePath); + }, + stat: (filePath) => { + const current = String(filePath); + return current.startsWith(`${fixture.projectCursor}${path.sep}`) || + current.startsWith(`${resolvedProjectCursor}${path.sep}`) + ? Effect.succeed(directoryInfo) + : fs.stat(filePath); + }, + }), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment).pipe( + Effect.provide(instrumented), + ); + + assert.isEmpty(skills); + // The root itself occupies one slot in the resolved-directory set. + assert.equal(emptyDirectoriesRead, MAX_DIRECTORIES_PER_ROOT - 1); + }), + ); + + it.effect("bounds entry inspection in a wide root containing no directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const entryNames = Array.from( + { length: MAX_ENTRIES_PER_ROOT + 50 }, + (_, index) => `file-${String(index).padStart(5, "0")}.txt`, + ); + let entriesStatted = 0; + + yield* fs.makeDirectory(fixture.projectCursor, { recursive: true }); + const resolvedProjectCursor = yield* fs.realPath(fixture.projectCursor); + const instrumented = Layer.succeed( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fs, + readDirectory: (directory, options) => + String(directory) === resolvedProjectCursor + ? Effect.succeed(entryNames) + : fs.readDirectory(directory, options), + stat: (filePath) => { + if (String(filePath).startsWith(`${resolvedProjectCursor}${path.sep}`)) { + entriesStatted += 1; + } + return fs.stat(filePath); + }, + }), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment).pipe( + Effect.provide(instrumented), + ); + + assert.isEmpty(skills); + assert.equal(entriesStatted, 0); + }), + ); + + /** + * A parsed-result assertion cannot tell a bounded read from a full one, so + * this watches the requested allocation size directly. + */ + it.effect("reads at most 64 KiB of metadata per SKILL.md", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const requestedSizes: Array = []; + + yield* writeSkill( + path.join(fixture.projectCursor, "huge"), + `${frontmatter("huge", "Has an enormous body.")}\n${"x".repeat(512 * 1024)}`, + ); + + const instrumented = Layer.succeed( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fs, + open: (filePath, options) => + fs.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + readAlloc: (size: FileSystem.SizeInput) => { + requestedSizes.push(Number(FileSystem.Size(size))); + return file.readAlloc(size); + }, + })), + ), + }), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment).pipe( + Effect.provide(instrumented), + ); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [["huge", "Has an enormous body."]], + ); + assert.deepEqual(requestedSizes, [64 * 1024]); + }), + ); +}); + +describe("resolveCursorHomeDirectory", () => { + it("prefers a non-empty merged HOME on POSIX", () => { + expect(resolveCursorHomeDirectory({ HOME: "/merged/home" }, "darwin")).toBe("/merged/home"); + }); + + it("prefers USERPROFILE over HOMEDRIVE and HOMEPATH on Windows", () => { + expect( + resolveCursorHomeDirectory( + { USERPROFILE: "C:\\Users\\merged", HOMEDRIVE: "D:", HOMEPATH: "\\Users\\other" }, + "win32", + ), + ).toBe("C:\\Users\\merged"); + }); + + it("falls back to a complete HOMEDRIVE and HOMEPATH pair on Windows", () => { + expect( + resolveCursorHomeDirectory({ HOMEDRIVE: "D:", HOMEPATH: "\\Users\\merged" }, "win32"), + ).toBe("D:\\Users\\merged"); + }); + + it("ignores blank values and an incomplete Windows pair", () => { + expect(resolveCursorHomeDirectory({ HOME: " " }, "linux")).toBe(NodeOS.homedir()); + expect(resolveCursorHomeDirectory({ HOMEDRIVE: "D:" }, "win32")).toBe(NodeOS.homedir()); + }); + + it("does not read the POSIX home from Windows variables", () => { + expect(resolveCursorHomeDirectory({ USERPROFILE: "C:\\Users\\merged" }, "linux")).toBe( + NodeOS.homedir(), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts new file mode 100644 index 00000000000..b49f51032e1 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -0,0 +1,324 @@ +/** + * CursorSkills — filesystem discovery of Cursor Agent skills for the `$` picker. + * + * Cursor loads skills from a user-scope and a project-scope pair of roots, one + * directory per skill with a `SKILL.md`. Unlike Codex, Cursor's ACP surface + * exposes skills only as slash-command metadata mixed in with commands and + * built-ins, without filesystem paths or a stable scope tag, so we scan the + * same locations directly. + * + * Discovery is per working directory: two projects in one environment have + * different project roots, and a thread running in a worktree resolves to that + * worktree. Nothing here is cached or hung off the provider status snapshot. + * + * ## Behavior verified against `cursor-agent` 2026.07.20-8cc9c0b + * + * Observed by driving a real `cursor-agent acp` session against a fixture + * project and reading its `available_commands_update` inventory. These rules + * are stricter or looser than Cursor's published docs in places; the docs lost: + * + * - A skill's name is its **directory name**. A `name:` in frontmatter that + * disagrees is ignored — Cursor listed `mismatched-dir`, not the + * `renamed-skill` its frontmatter declared. + * - `description` is optional. When frontmatter omits it (or omits + * frontmatter entirely) Cursor falls back to the body's first Markdown + * heading, so we do too rather than showing a blank picker row. + * - Frontmatter is optional and its absence is not an error. + * - Names are not restricted to lowercase ASCII and hyphens; a + * `Weird_Name` directory loaded and listed verbatim. + * - `.cursor/skills` wins over `.agents/skills` within the same scope: a + * name present in both listed once, with the `.cursor` description. + * - Roots are searched recursively, so `shipping/land-it/SKILL.md` loads. + * + * Invocation was also verified there: `$name`, `/name`, and even a bare `name` + * all load the skill, because Cursor resolves skills model-side from injected + * metadata rather than expanding a slash token in the harness. Cursor rows + * therefore use `$name`, which preserves composer chips and avoids colliding + * with the slash-command trigger. + * + * @module provider/Drivers/CursorSkills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +type CursorSkillScope = "user" | "project"; + +interface CursorSkillRoot { + readonly directory: string; + readonly boundary: string; + readonly scope: CursorSkillScope; +} + +/** + * Cap on metadata bytes read per `SKILL.md`. Skill bodies are prose and can be + * long; we only ever need the frontmatter and the first heading, and the body + * must never cross the wire. + */ +const SKILL_METADATA_READ_LIMIT = 64 * 1024; + +/** Guard against a pathological skills root; far above any real inventory. */ +const MAX_SKILLS_PER_ROOT = 500; + +/** Bound traversal even when a pathological root contains no skills. */ +export const MAX_DIRECTORIES_PER_ROOT = 2_000; + +/** Bound per-entry sorting and stat work in unusually wide directory trees. */ +export const MAX_ENTRIES_PER_ROOT = 10_000; + +const FRONTMATTER_PATTERN = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; +const HEADING_PATTERN = /^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/m; +const UTF8_BOM = "\ufeff"; + +const utf8Decoder = new TextDecoder("utf-8"); + +/** + * Pull a display description out of `SKILL.md`, preferring frontmatter and + * falling back to the body's first heading the way Cursor does. Returns + * `undefined` when neither is usable; the picker then shows the name alone. + */ +function parseSkillDescription(contents: string): string | undefined { + const body = contents.startsWith(UTF8_BOM) ? contents.slice(UTF8_BOM.length) : contents; + const match = FRONTMATTER_PATTERN.exec(body); + + if (match) { + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + parsed = undefined; + } + if (typeof parsed === "object" && parsed !== null) { + const declared = (parsed as Record).description; + if (typeof declared === "string" && declared.trim().length > 0) { + return declared.trim(); + } + } + } + + const heading = HEADING_PATTERN.exec(match ? body.slice(match[0].length) : body); + const headingText = heading?.[1]?.trim(); + return headingText && headingText.length > 0 ? headingText : undefined; +} + +/** + * Resolve the home directory Cursor's subprocess would see. Callers pass the + * already-merged provider instance environment so discovery and the spawned + * CLI agree; `os.homedir()` is only a last resort. Exported for tests, which + * exercise the Windows branches without a Windows worker. + * + * `platform` comes from `HostProcessPlatform` so tests can drive either branch. + */ +export function resolveCursorHomeDirectory( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string { + const read = (key: string): string | undefined => { + const value = environment[key]?.trim(); + return value && value.length > 0 ? value : undefined; + }; + + if (platform === "win32") { + const userProfile = read("USERPROFILE"); + if (userProfile) return userProfile; + const homeDrive = read("HOMEDRIVE"); + const homePath = read("HOMEPATH"); + if (homeDrive && homePath) return `${homeDrive}${homePath}`; + } else { + const home = read("HOME"); + if (home) return home; + } + + return NodeOS.homedir(); +} + +/** + * The four roots Cursor reads, lowest precedence first. Later roots overwrite + * earlier ones by name, so `.cursor` beating `.agents` and project beating + * user both fall out of the ordering. + */ +function buildSkillRoots(input: { + readonly path: Path.Path; + readonly homeDirectory: string; + readonly cwd: string; +}): ReadonlyArray { + const home = input.path.resolve(input.homeDirectory); + const workspace = input.path.resolve(input.cwd); + return [ + { directory: input.path.join(home, ".agents", "skills"), boundary: home, scope: "user" }, + { directory: input.path.join(home, ".cursor", "skills"), boundary: home, scope: "user" }, + { + directory: input.path.join(workspace, ".agents", "skills"), + boundary: workspace, + scope: "project", + }, + { + directory: input.path.join(workspace, ".cursor", "skills"), + boundary: workspace, + scope: "project", + }, + ]; +} + +/** + * Read at most `SKILL_METADATA_READ_LIMIT` bytes of a `SKILL.md`. A single + * bounded `readAlloc` rather than `readFileString` plus a slice, so a huge + * skill body is never pulled into memory. Unreadable files yield `undefined` + * and skip only that skill. + */ +const readSkillMetadata = Effect.fn("readSkillMetadata")(function* ( + skillPath: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(skillPath, { flag: "r" }); + const bytes = yield* file.readAlloc(FileSystem.Size(SKILL_METADATA_READ_LIMIT)); + return Option.match(bytes, { + onNone: () => "", + onSome: (value) => utf8Decoder.decode(value), + }); + }), + ).pipe(Effect.orElseSucceed(() => undefined)); +}); + +/** + * Walk one skills root, collecting every directory that directly contains a + * `SKILL.md`. Recursion is what makes organizational category directories + * (`shipping/land-it/SKILL.md`) work, and is confined to the four known roots + * — this never scans the workspace looking for hidden roots. + * + * Symlinked skill directories are followed only while their canonical target + * remains inside the canonical skill root. The root itself must remain inside + * its user-home or workspace boundary. Resolved paths are tracked so a cycle + * terminates, and an unreadable directory skips only its subtree. + */ +const collectSkillsInRoot = Effect.fn("collectSkillsInRoot")(function* (input: { + readonly root: CursorSkillRoot; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const collected: Array = []; + const visited = new Set(); + let entriesInspected = 0; + + const isWithin = (candidate: string, root: string): boolean => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) + ); + }; + + const resolvedBoundary = yield* fileSystem + .realPath(input.root.boundary) + .pipe(Effect.orElseSucceed(() => undefined)); + const resolvedRoot = yield* fileSystem + .realPath(input.root.directory) + .pipe(Effect.orElseSucceed(() => undefined)); + if ( + resolvedBoundary === undefined || + resolvedRoot === undefined || + !isWithin(resolvedRoot, resolvedBoundary) + ) { + return collected; + } + + const walk = (directory: string): Effect.Effect => + Effect.gen(function* () { + if ( + collected.length >= MAX_SKILLS_PER_ROOT || + visited.size >= MAX_DIRECTORIES_PER_ROOT || + entriesInspected >= MAX_ENTRIES_PER_ROOT + ) { + return; + } + + const resolved = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => undefined)); + if (resolved === undefined || !isWithin(resolved, resolvedRoot)) return; + if (visited.has(resolved)) return; + if (visited.size >= MAX_DIRECTORIES_PER_ROOT) return; + visited.add(resolved); + + const entries = yield* fileSystem + .readDirectory(resolved) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const remainingEntries = MAX_ENTRIES_PER_ROOT - entriesInspected; + if (entries.length > remainingEntries) return; + entriesInspected += entries.length; + + const skillPath = path.join(directory, "SKILL.md"); + if (entries.includes("SKILL.md")) { + const contents = yield* readSkillMetadata(path.join(resolved, "SKILL.md")); + // Cursor names a skill after the directory that holds its SKILL.md, + // ignoring any frontmatter `name`. + const name = path.basename(directory).trim(); + if (contents !== undefined && name.length > 0) { + const description = parseSkillDescription(contents); + collected.push({ + name, + path: skillPath, + enabled: true, + scope: input.root.scope, + ...(description ? { description } : {}), + }); + } + } + + for (const entry of [...entries].sort()) { + if (collected.length >= MAX_SKILLS_PER_ROOT || visited.size >= MAX_DIRECTORIES_PER_ROOT) { + return; + } + if (entry === "SKILL.md") continue; + const child = path.join(directory, entry); + const info = yield* fileSystem.stat(child).pipe(Effect.orElseSucceed(() => undefined)); + if (info?.type !== "Directory") continue; + yield* walk(child); + } + }); + + yield* walk(input.root.directory); + return collected; +}); + +/** + * Enumerate the Cursor skills available to an agent running in `cwd`. + * + * `environment` must be the merged provider instance environment so the home + * roots match the ones the spawned `cursor-agent` reads. Discovery is + * best-effort and has no error channel: an unreadable root, directory, or file + * removes only what it covers, so a broken skill can never degrade provider + * health or fail the picker. + */ +export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( + cwd: string, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const roots = buildSkillRoots({ + path, + homeDirectory: resolveCursorHomeDirectory(environment, platform), + cwd, + }); + + // Lowest precedence first, so a later `set` is the more specific root + // winning: project over user, and `.cursor` over `.agents` within a scope. + const skillsByName = new Map(); + for (const root of roots) { + for (const skill of yield* collectSkillsInRoot({ root })) { + skillsByName.set(skill.name, skill); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.snapshotSource.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.snapshotSource.test.ts new file mode 100644 index 00000000000..ed371ffa544 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderRegistry.snapshotSource.test.ts @@ -0,0 +1,56 @@ +import { assert, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { buildSnapshotSource } from "./ProviderRegistry.ts"; + +it.effect("derives project-scoped skill inventory on every snapshot channel", () => + Effect.gen(function* () { + const cursorDriver = ProviderDriverKind.make("cursor"); + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const provider = { + instanceId: cursorInstanceId, + driver: cursorDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-31T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: cursorInstanceId, + driverKind: cursorDriver, + continuationIdentity: { + driverKind: cursorDriver, + continuationKey: "cursor:instance:cursor", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: cursorDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.make(provider), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + skillInventory: { list: () => Effect.succeed([]) }, + } satisfies ProviderInstance; + + const source = buildSnapshotSource(instance); + const streamed = yield* Stream.runCollect(source.streamChanges); + const snapshots = [yield* source.getSnapshot, yield* source.refresh, ...streamed]; + + assert.isTrue(snapshots.every((snapshot) => snapshot.skillInventoryMode === "project")); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e..ca070caa6fc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -198,13 +198,20 @@ const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { // after `ProviderInstanceRegistry` rebuilds an instance (e.g. because // its settings changed), a fresh source rides the new PubSub instead // of a closed one. -const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ - instanceId: instance.instanceId, - driverKind: instance.driverKind, - getSnapshot: instance.snapshot.getSnapshot, - refresh: instance.snapshot.refresh, - streamChanges: instance.snapshot.streamChanges, -}); +export const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => { + const decorateSnapshot = (snapshot: ServerProvider): ServerProvider => + instance.skillInventory === undefined + ? snapshot + : { ...snapshot, skillInventoryMode: "project" }; + + return { + instanceId: instance.instanceId, + driverKind: instance.driverKind, + getSnapshot: instance.snapshot.getSnapshot.pipe(Effect.map(decorateSnapshot)), + refresh: instance.snapshot.refresh.pipe(Effect.map(decorateSnapshot)), + streamChanges: instance.snapshot.streamChanges.pipe(Stream.map(decorateSnapshot)), + }; +}; export const ProviderRegistryLive = Layer.effect( ProviderRegistry, diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a..5b50883a33a 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderSkill, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -52,6 +53,22 @@ export interface ProviderDriverMetadata { readonly supportsMultipleInstances?: boolean; } +/** + * Optional capability for drivers whose skill inventory depends on where the + * agent runs, rather than being a property of the environment as a whole. + * + * `ProviderRegistry` derives the matching wire capability from this field, so + * drivers only declare project-scoped inventory once. + * + * Discovery is best-effort by contract: no error channel, because a broken + * skill must never fail the picker or downgrade provider health. + */ +export interface ProviderSkillInventory { + readonly list: (input: { + readonly cwd: string; + }) => Effect.Effect>; +} + /** * One materialized provider instance. Held by the registry, looked up by * `instanceId`, torn down by closing the scope it was created in. @@ -71,6 +88,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + /** Absent for drivers whose `snapshot.skills` is the whole inventory. */ + readonly skillInventory?: ProviderSkillInventory; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/ProviderSkillInventory.test.ts b/apps/server/src/provider/ProviderSkillInventory.test.ts new file mode 100644 index 00000000000..255ff61ae8a --- /dev/null +++ b/apps/server/src/provider/ProviderSkillInventory.test.ts @@ -0,0 +1,255 @@ +import { assert, it } from "@effect/vitest"; +import { + ProjectId, + ProviderInstanceId, + type ServerProvider, + type ServerProviderSkill, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { resolveProviderSkillInventory } from "./ProviderSkillInventory.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; + +const PROJECT_ID = ProjectId.make("project-a"); +const OTHER_PROJECT_ID = ProjectId.make("project-b"); +const THREAD_ID = ThreadId.make("thread-1"); +const INSTANCE_ID = ProviderInstanceId.make("cursor-default"); + +const skill = (name: string): ServerProviderSkill => ({ + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, +}); + +/** + * A provider instance with only the fields this module touches. The rest of + * `ProviderInstance` is adapter/text-generation surface that inventory + * resolution never reaches. + */ +const makeInstance = (input: { + readonly snapshotSkills?: ReadonlyArray; + readonly inventory?: (cwd: string) => ReadonlyArray; + readonly observedCwds?: Array; +}): ProviderInstance => + ({ + instanceId: INSTANCE_ID, + snapshot: { + getSnapshot: Effect.succeed({ skills: input.snapshotSkills ?? [] } as ServerProvider), + }, + ...(input.inventory + ? { + skillInventory: { + list: ({ cwd }: { readonly cwd: string }) => { + input.observedCwds?.push(cwd); + return Effect.succeed(input.inventory?.(cwd) ?? []); + }, + }, + } + : {}), + }) as unknown as ProviderInstance; + +/** + * Only the two read-model lookups this module performs are stubbed; the + * projected rows carry just the fields cwd resolution reads. + */ +const projectionLayer = (input: { + readonly projects?: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; + readonly thread?: { readonly projectId: ProjectId; readonly worktreePath: string | null }; +}) => + Layer.mock(ProjectionSnapshotQuery)({ + getProjectShellById: ((projectId: ProjectId) => + Effect.succeed( + Option.fromNullishOr(input.projects?.find((project) => project.id === projectId)), + )) as unknown as ProjectionSnapshotQuery["Service"]["getProjectShellById"], + getThreadShellById: (() => + Effect.succeed( + Option.fromNullishOr(input.thread), + )) as unknown as ProjectionSnapshotQuery["Service"]["getThreadShellById"], + }); + +const registryLayer = (instance: ProviderInstance | undefined) => + Layer.mock(ProviderInstanceRegistry)({ + getInstance: () => Effect.succeed(instance), + }); + +it.effect("resolves project scope to the project's authoritative workspace root", () => + Effect.gen(function* () { + const observedCwds: Array = []; + const skills = yield* resolveProviderSkillInventory({ + scope: { kind: "project", projectId: PROJECT_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: (cwd) => [skill(cwd)], observedCwds })), + projectionLayer({ + projects: [ + { id: PROJECT_ID, workspaceRoot: "/repos/a" }, + { id: OTHER_PROJECT_ID, workspaceRoot: "/repos/b" }, + ], + }), + ), + ), + ); + + assert.deepEqual(observedCwds, ["/repos/a"]); + assert.deepEqual( + skills.map((entry) => entry.name), + ["/repos/a"], + ); + }), +); + +/** + * The finding that motivated the scope shape: a worktree thread's agent runs + * in the worktree, so the picker must list the worktree's skills. + */ +it.effect("resolves thread scope to the thread's worktree when it has one", () => + Effect.gen(function* () { + const observedCwds: Array = []; + yield* resolveProviderSkillInventory({ + scope: { kind: "thread", threadId: THREAD_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: () => [], observedCwds })), + projectionLayer({ + projects: [{ id: PROJECT_ID, workspaceRoot: "/repos/a" }], + thread: { projectId: PROJECT_ID, worktreePath: "/repos/a-worktrees/feature" }, + }), + ), + ), + ); + + assert.deepEqual(observedCwds, ["/repos/a-worktrees/feature"]); + }), +); + +it.effect("resolves a worktree thread even when the project row is missing", () => + Effect.gen(function* () { + const observedCwds: Array = []; + yield* resolveProviderSkillInventory({ + scope: { kind: "thread", threadId: THREAD_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: () => [], observedCwds })), + projectionLayer({ + projects: [], + thread: { projectId: PROJECT_ID, worktreePath: "/repos/a-worktrees/feature" }, + }), + ), + ), + ); + + assert.deepEqual(observedCwds, ["/repos/a-worktrees/feature"]); + }), +); + +it.effect("falls back to the project workspace root for a thread without a worktree", () => + Effect.gen(function* () { + const observedCwds: Array = []; + yield* resolveProviderSkillInventory({ + scope: { kind: "thread", threadId: THREAD_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: () => [], observedCwds })), + projectionLayer({ + projects: [{ id: PROJECT_ID, workspaceRoot: "/repos/a" }], + thread: { projectId: PROJECT_ID, worktreePath: null }, + }), + ), + ), + ); + + assert.deepEqual(observedCwds, ["/repos/a"]); + }), +); + +it.effect("returns snapshot skills for a provider without the capability", () => + Effect.gen(function* () { + const skills = yield* resolveProviderSkillInventory({ + scope: { kind: "project", projectId: PROJECT_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ snapshotSkills: [skill("from-snapshot")] })), + // Deliberately empty: a snapshot-mode provider must answer without + // the scope resolving to anything at all. + projectionLayer({}), + ), + ), + ); + + assert.deepEqual( + skills.map((entry) => entry.name), + ["from-snapshot"], + ); + }), +); + +it.effect("fails with a typed error for an unknown instance", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + resolveProviderSkillInventory({ + scope: { kind: "project", projectId: PROJECT_ID }, + instanceId: INSTANCE_ID, + }).pipe(Effect.provide(Layer.mergeAll(registryLayer(undefined), projectionLayer({})))), + ); + + assert.equal(error._tag, "ServerProviderSkillInventoryError"); + assert.include(error.reason, INSTANCE_ID); + }), +); + +it.effect("fails with a typed error for an unknown project", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + resolveProviderSkillInventory({ + scope: { kind: "project", projectId: PROJECT_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: () => [] })), + projectionLayer({ projects: [] }), + ), + ), + ), + ); + + assert.equal(error._tag, "ServerProviderSkillInventoryError"); + assert.include(error.reason, PROJECT_ID); + }), +); + +it.effect("fails with a typed error for an unknown thread", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + resolveProviderSkillInventory({ + scope: { kind: "thread", threadId: THREAD_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: () => [] })), + projectionLayer({ projects: [{ id: PROJECT_ID, workspaceRoot: "/repos/a" }] }), + ), + ), + ), + ); + + assert.equal(error._tag, "ServerProviderSkillInventoryError"); + assert.include(error.reason, THREAD_ID); + }), +); diff --git a/apps/server/src/provider/ProviderSkillInventory.ts b/apps/server/src/provider/ProviderSkillInventory.ts new file mode 100644 index 00000000000..ea905e591f7 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillInventory.ts @@ -0,0 +1,116 @@ +/** + * ProviderSkillInventory — resolve a provider's skill inventory for the + * directory an agent will actually run in. + * + * The `$` picker used to render `ServerProvider.skills`, an environment-wide + * snapshot computed once from wherever the server started. That is wrong for + * every project but one, and useless for Cursor, which reports no snapshot + * skills at all. + * + * This module takes a thread or project identifier — never a path — resolves + * it to a working directory with the same rule the provider session uses, and + * asks the selected instance for its inventory there. Instances without the + * capability fall back to their snapshot skills, so Codex and Claude keep + * working unchanged. + * + * @module provider/ProviderSkillInventory + */ +import { + type ServerProviderSkill, + ServerProviderSkillInventoryError, + type ServerProviderSkillInventoryInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; + +/** Wrap a read-model failure in the RPC's typed inventory error. */ +const repositoryFailure = (reason: string) => (cause: unknown) => + new ServerProviderSkillInventoryError({ reason, cause }); + +/** + * Resolve the inventory scope to the directory a provider session for that + * scope would be started in. + * + * Thread scope reuses `resolveThreadWorkspaceCwd`, the same helper + * `ProviderCommandReactor` uses, rather than re-deriving + * `worktreePath ?? workspaceRoot` — a second copy would drift and the picker + * would list the project root's skills for a worktree thread. + */ +const resolveInventoryCwd = Effect.fn("resolveInventoryCwd")(function* ( + scope: ServerProviderSkillInventoryInput["scope"], +): Effect.fn.Return { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + if (scope.kind === "project") { + const project = yield* projectionSnapshotQuery + .getProjectShellById(scope.projectId) + .pipe(Effect.mapError(repositoryFailure("Failed to read the project read model."))); + if (Option.isNone(project)) { + return yield* new ServerProviderSkillInventoryError({ + reason: `Unknown project '${scope.projectId}'.`, + }); + } + return project.value.workspaceRoot; + } + + const thread = yield* projectionSnapshotQuery + .getThreadShellById(scope.threadId) + .pipe(Effect.mapError(repositoryFailure("Failed to read the thread read model."))); + if (Option.isNone(thread)) { + return yield* new ServerProviderSkillInventoryError({ + reason: `Unknown thread '${scope.threadId}'.`, + }); + } + + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.value.projectId) + .pipe(Effect.mapError(repositoryFailure("Failed to read the project read model."))); + + const cwd = resolveThreadWorkspaceCwd({ + thread: thread.value, + projects: Option.isSome(project) ? [project.value] : [], + }); + if (cwd === undefined) { + return yield* new ServerProviderSkillInventoryError({ + reason: `Thread '${scope.threadId}' has no resolvable workspace directory.`, + }); + } + return cwd; +}); + +/** + * Answer one `providers.skillInventory` request. + * + * Unknown scopes and unknown instances are typed failures. Discovery itself + * cannot fail: a provider that scans the filesystem returns whatever it could + * read, so a malformed skill costs that row and nothing else. + */ +export const resolveProviderSkillInventory = Effect.fn("resolveProviderSkillInventory")(function* ( + input: ServerProviderSkillInventoryInput, +): Effect.fn.Return< + ReadonlyArray, + ServerProviderSkillInventoryError, + ProjectionSnapshotQuery | ProviderInstanceRegistry +> { + const instanceRegistry = yield* ProviderInstanceRegistry; + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (instance === undefined) { + return yield* new ServerProviderSkillInventoryError({ + reason: `Unknown provider instance '${input.instanceId}'.`, + }); + } + + // Snapshot-mode providers never reach the filesystem, and must not pay for + // resolving a cwd they will not use. + if (instance.skillInventory === undefined) { + const snapshot = yield* instance.snapshot.getSnapshot; + return snapshot.skills; + } + + const cwd = yield* resolveInventoryCwd(input.scope); + return yield* instance.skillInventory.list({ cwd }); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fff71dbb4e7..532f79b50ae 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -85,6 +85,7 @@ import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; +import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -330,6 +331,9 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerInstanceRegistry?: Partial< + ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] + >; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -565,18 +569,27 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: () => Effect.succeed(undefined), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerInstanceRegistry, + }), + ), ), Layer.provide( Layer.mock(ServerSettings.ServerSettingsService)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 06888ef3f70..ce70dc98bf9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,7 +78,9 @@ import { observeRpcStream as instrumentRpcStream, observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; +import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { resolveProviderSkillInventory } from "./provider/ProviderSkillInventory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -359,6 +361,7 @@ const makeWsRpcLayer = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerInstanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -1370,6 +1373,22 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.providersSkillInventory]: (input) => + observeRpcEffect( + WS_METHODS.providersSkillInventory, + resolveProviderSkillInventory(input).pipe( + Effect.map((skills) => ({ skills })), + Effect.provideService( + ProviderInstanceRegistry.ProviderInstanceRegistry, + providerInstanceRegistry, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + ), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..ccb4ab3f439 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -201,6 +201,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useProviderSkills } from "../state/queries"; import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, @@ -311,7 +312,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; -const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -2478,6 +2478,14 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const timelineSkills = useProviderSkills({ + activeEnvironmentId: activeThread?.environmentId, + fallbackEnvironmentId: environmentId, + provider: activeProviderStatus, + isServerThread, + threadId: activeThreadId, + projectId: activeProject?.id ?? null, + }); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -5822,7 +5830,7 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={timelineSkills} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} @@ -5948,6 +5956,7 @@ function ChatViewContent(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + activeProjectId={activeProject?.id ?? null} activeProjectDefaultModelSelection={ activeProject?.defaultModelSelection } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e92ecd497e3..9c5469bf7c5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3,6 +3,7 @@ import type { EnvironmentId, ModelSelection, PreviewAnnotationPayload, + ProjectId, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, @@ -76,6 +77,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderSkills } from "../../state/queries"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -565,6 +567,7 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + activeProjectId: ProjectId | null; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -629,9 +632,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) routeThreadRef, draftId, activeThreadId, - activeThreadEnvironmentId: _activeThreadEnvironmentId, + activeThreadEnvironmentId, activeThread, - isServerThread: _isServerThread, + isServerThread, isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, projectSelectionRequired, @@ -660,6 +663,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, lockedProvider, providerStatuses, + activeProjectId, activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadActivities, @@ -1048,6 +1052,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) query: isPathTrigger ? pathTriggerQuery : null, }); + const composerSkills = useProviderSkills({ + activeEnvironmentId: activeThreadEnvironmentId, + fallbackEnvironmentId: environmentId, + provider: selectedProviderStatus, + isServerThread, + threadId: activeThreadId, + projectId: activeProjectId, + }); + const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1102,22 +1115,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(composerSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerSkills, + composerTrigger, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -3048,7 +3065,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={composerSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..2248c751dfe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -464,6 +464,19 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Show full message"); }, 20_000); + it("renders resolved provider skills in sent user messages", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("iOS Debugger Agent"); + expect(markup).toContain('data-markdown-copy="$ios-debugger-agent"'); + }); + it("renders chips for standalone element-pick context messages", () => { const markup = renderToStaticMarkup( { + const target = resolveProviderSkillInventoryTarget(context); + const request = resolveProviderSkillInventoryRequest(target); + const result = useEnvironmentQuery( + request === null ? null : serverEnvironment.skillInventory(request), + ); + const provider = target.provider; + const inventory = result.data; + return useMemo(() => selectProviderSkills({ provider, inventory }), [provider, inventory]); +} + interface ProjectContentSearchTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03d..ab8b6f064d0 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -39,6 +39,33 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +## Skill inventory + +Most drivers publish their skills once, as `ServerProvider.skills` on the status snapshot, and the +composer's `$` menu renders that array with no round trip. + +Cursor cannot: its skills come from `.cursor/skills` and `.agents/skills` under both the user's home +and the workspace, so the answer depends on the directory the agent will run in — a different project +or a worktree thread has a different inventory. Such a driver implements the optional +`skillInventory` capability on [`ProviderInstance`][adapterdriver]. The provider registry derives +`skillInventoryMode: "project"` on every published snapshot, so the capability has one source of +truth. Clients see that flag and call the `providers.skillInventory` RPC with a thread or project id +instead of reading `skills`. + +For instances with `skillInventory`, the RPC resolves the scope to a working directory server-side +([`ProviderSkillInventory.ts`][skills], reusing `resolveThreadWorkspaceCwd` so a worktree thread lists +its worktree's skills) and then asks the instance. Snapshot-mode providers intentionally bypass +working-directory resolution and return the cached `snapshot.skills` directly. The RPC accepts an +identifier rather than a path, so the surface cannot become a filesystem probe. Request-level failures +(unknown instance, unknown project or thread, unresolvable scope) are reported through the tagged +`ServerProviderSkillInventoryError` contract. Once discovery begins, per-entry filesystem failures +during traversal have no error channel: an unreadable root or directory removes every skill in that +subtree, while an unreadable `SKILL.md` removes only that skill; malformed metadata uses Cursor's +documented fallback rules when possible. Canonical path checks reject skill roots that leave their user-home or +workspace boundary and nested symlinks that leave the skill root; directory plus entry budgets bound +traversal work. Cursor's own scan lives in [`CursorSkills.ts`][cursorskills], which documents the +naming, precedence, and fallback rules verified against the real CLI. + ## How provider work is requested Clients never call a provider directly. They dispatch orchestration commands over the RPC method @@ -82,6 +109,9 @@ when a request opens (approval) or user input is requested, via [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts +[adapterdriver]: ../../apps/server/src/provider/ProviderDriver.ts +[skills]: ../../apps/server/src/provider/ProviderSkillInventory.ts +[cursorskills]: ../../apps/server/src/provider/Drivers/CursorSkills.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts [service]: ../../apps/server/src/provider/Layers/ProviderService.ts diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0b7b078a522..efd8864d307 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" }, + "./state/provider-skill-inventory": { + "types": "./src/state/providerSkillInventory.ts", + "default": "./src/state/providerSkillInventory.ts" + }, "./state/project-grouping": { "types": "./src/state/projectGrouping.ts", "default": "./src/state/projectGrouping.ts" diff --git a/packages/client-runtime/src/state/providerSkillInventory.test.ts b/packages/client-runtime/src/state/providerSkillInventory.test.ts new file mode 100644 index 00000000000..2fcf22c757d --- /dev/null +++ b/packages/client-runtime/src/state/providerSkillInventory.test.ts @@ -0,0 +1,195 @@ +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderSkill, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + type ProviderSkillInventoryTarget, + resolveProviderSkillInventoryRequest, + resolveProviderSkillInventoryTarget, + selectProviderSkills, +} from "./providerSkillInventory.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("env-1"); +const FALLBACK_ENVIRONMENT_ID = EnvironmentId.make("env-fallback"); +const THREAD_ID = ThreadId.make("thread-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const INSTANCE_ID = ProviderInstanceId.make("cursor-default"); + +const skill = (name: string): ServerProviderSkill => ({ + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, +}); + +const provider = (input: { + readonly mode?: "project"; + readonly skills?: ReadonlyArray; +}): ServerProvider => + ({ + instanceId: INSTANCE_ID, + driver: ProviderDriverKind.make("cursor"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: input.skills ?? [], + ...(input.mode ? { skillInventoryMode: input.mode } : {}), + }) as ServerProvider; + +const target = ( + overrides?: Partial[0]>, +): ProviderSkillInventoryTarget => ({ + environmentId: ENVIRONMENT_ID, + provider: provider({ mode: "project" }), + scope: { kind: "thread", threadId: THREAD_ID }, + ...overrides, +}); + +describe("resolveProviderSkillInventoryTarget", () => { + it("uses the active thread environment and thread scope for a server thread", () => { + expect( + resolveProviderSkillInventoryTarget({ + activeEnvironmentId: ENVIRONMENT_ID, + fallbackEnvironmentId: FALLBACK_ENVIRONMENT_ID, + provider: provider({ mode: "project" }), + isServerThread: true, + threadId: THREAD_ID, + projectId: PROJECT_ID, + }), + ).toEqual({ + environmentId: ENVIRONMENT_ID, + provider: provider({ mode: "project" }), + scope: { kind: "thread", threadId: THREAD_ID }, + }); + }); + + it("uses fallback environment and project scope for a local draft with a preallocated id", () => { + expect( + resolveProviderSkillInventoryTarget({ + activeEnvironmentId: null, + fallbackEnvironmentId: FALLBACK_ENVIRONMENT_ID, + provider: provider({ mode: "project" }), + isServerThread: false, + threadId: THREAD_ID, + projectId: PROJECT_ID, + }), + ).toEqual({ + environmentId: FALLBACK_ENVIRONMENT_ID, + provider: provider({ mode: "project" }), + scope: { kind: "project", projectId: PROJECT_ID }, + }); + }); + + it("leaves scope unresolved when neither a durable thread nor project exists", () => { + expect( + resolveProviderSkillInventoryTarget({ + activeEnvironmentId: null, + provider: null, + isServerThread: false, + threadId: THREAD_ID, + projectId: null, + }), + ).toEqual({ environmentId: null, provider: null, scope: null }); + }); +}); + +describe("resolveProviderSkillInventoryRequest", () => { + it("requests thread scope when composing inside a thread", () => { + expect(resolveProviderSkillInventoryRequest(target())).toEqual({ + environmentId: ENVIRONMENT_ID, + input: { scope: { kind: "thread", threadId: THREAD_ID }, instanceId: INSTANCE_ID }, + }); + }); + + it("requests project scope for a new-task draft with no thread yet", () => { + expect( + resolveProviderSkillInventoryRequest( + target({ scope: { kind: "project", projectId: PROJECT_ID } }), + ), + ).toEqual({ + environmentId: ENVIRONMENT_ID, + input: { scope: { kind: "project", projectId: PROJECT_ID }, instanceId: INSTANCE_ID }, + }); + }); + + it("issues no request for a snapshot-mode provider", () => { + expect(resolveProviderSkillInventoryRequest(target({ provider: provider({}) }))).toBeNull(); + }); + + it("issues no request when the scope, environment, or provider is unknown", () => { + expect(resolveProviderSkillInventoryRequest(target({ scope: null }))).toBeNull(); + expect(resolveProviderSkillInventoryRequest(target({ environmentId: null }))).toBeNull(); + expect(resolveProviderSkillInventoryRequest(target({ provider: null }))).toBeNull(); + }); + + /** + * The key is what the query layer dedupes and caches on, so it must be + * identical across consumers of the same scope and different across scopes. + */ + it("keys by environment, scope, and instance only", () => { + const first = resolveProviderSkillInventoryRequest(target()); + const sameScope = resolveProviderSkillInventoryRequest(target()); + expect(sameScope).toEqual(first); + + const otherThread = resolveProviderSkillInventoryRequest( + target({ scope: { kind: "thread", threadId: ThreadId.make("thread-2") } }), + ); + expect(otherThread).not.toEqual(first); + + const otherInstance = resolveProviderSkillInventoryRequest( + target({ + provider: { + ...provider({ mode: "project" }), + instanceId: ProviderInstanceId.make("cursor-second"), + }, + }), + ); + expect(otherInstance).not.toEqual(first); + }); +}); + +describe("selectProviderSkills", () => { + it("uses snapshot skills for a snapshot-mode provider even if inventory exists", () => { + expect( + selectProviderSkills({ + provider: provider({ skills: [skill("from-snapshot")] }), + inventory: { skills: [skill("from-rpc")] }, + }).map((entry) => entry.name), + ).toEqual(["from-snapshot"]); + }); + + it("uses the fetched inventory for a project-mode provider", () => { + expect( + selectProviderSkills({ + provider: provider({ mode: "project", skills: [skill("from-snapshot")] }), + inventory: { skills: [skill("from-rpc")] }, + }).map((entry) => entry.name), + ).toEqual(["from-rpc"]); + }); + + it("falls back to snapshot skills before the first response and after a failure", () => { + expect( + selectProviderSkills({ + provider: provider({ mode: "project", skills: [skill("from-snapshot")] }), + inventory: null, + }).map((entry) => entry.name), + ).toEqual(["from-snapshot"]); + }); + + it("renders an empty picker when no provider is selected", () => { + expect(selectProviderSkills({ provider: null, inventory: { skills: [skill("x")] } })).toEqual( + [], + ); + }); +}); diff --git a/packages/client-runtime/src/state/providerSkillInventory.ts b/packages/client-runtime/src/state/providerSkillInventory.ts new file mode 100644 index 00000000000..76de2d624be --- /dev/null +++ b/packages/client-runtime/src/state/providerSkillInventory.ts @@ -0,0 +1,115 @@ +/** + * Provider skill inventory selection, shared by web and mobile. + * + * A provider that advertises `skillInventoryMode: "project"` computes its + * skills from the directory the agent will run in, so client surfaces ask the + * environment server instead of reading `ServerProvider.skills`. Everyone else + * keeps the snapshot array and issues no request at all. + * + * Only the decisions live here — whether to ask, what to ask for, and which + * rows to render. Each client keeps its own `useEnvironmentQuery` binding. + * + * @module state/providerSkillInventory + */ +import { + type EnvironmentId, + type ProjectId, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderSkill, + type ServerProviderSkillInventoryInput, + type ServerProviderSkillInventoryResult, + type ServerProviderSkillInventoryScope, + type ThreadId, + usesProjectSkillInventory, +} from "@t3tools/contracts"; + +const NO_SKILLS: ReadonlyArray = []; + +export interface ProviderSkillInventoryTarget { + readonly environmentId: EnvironmentId | null; + /** The provider snapshot backing the current model selection. */ + readonly provider: ServerProvider | null; + /** The thread or project whose working directory the provider will use. */ + readonly scope: ServerProviderSkillInventoryScope | null; +} + +/** + * Surface context from which the shared client derives one inventory target. + * Local drafts can already have a thread id, so `isServerThread` is the + * authority for choosing thread scope instead of treating any id as durable. + */ +export interface ProviderSkillInventoryContext { + readonly activeEnvironmentId: EnvironmentId | null | undefined; + readonly fallbackEnvironmentId?: EnvironmentId | null; + readonly provider: ServerProvider | null; + readonly isServerThread: boolean; + readonly threadId: ThreadId | null; + readonly projectId: ProjectId | null; +} + +export interface ProviderSkillInventoryRequest { + readonly environmentId: EnvironmentId; + readonly input: ServerProviderSkillInventoryInput; +} + +/** Resolve surface state into the single target shape used by web and mobile. */ +export function resolveProviderSkillInventoryTarget( + context: ProviderSkillInventoryContext, +): ProviderSkillInventoryTarget { + return { + environmentId: context.activeEnvironmentId ?? context.fallbackEnvironmentId ?? null, + provider: context.provider, + scope: + context.isServerThread && context.threadId !== null + ? { kind: "thread", threadId: context.threadId } + : context.projectId !== null + ? { kind: "project", projectId: context.projectId } + : null, + }; +} + +/** + * The request backing a project-scoped provider, or `null` when no request + * should be made because the provider uses snapshot skills or the scope cannot + * be identified. + * + * The result is a stable inventory key: it changes with the environment, the + * scope, and the provider instance, and with nothing else. In particular the + * user's query text is absent, so typing filters locally instead of refetching. + */ +export function resolveProviderSkillInventoryRequest( + target: ProviderSkillInventoryTarget, +): ProviderSkillInventoryRequest | null { + if (target.environmentId === null || target.provider === null || target.scope === null) { + return null; + } + if (!usesProjectSkillInventory(target.provider)) { + return null; + } + + const instanceId: ProviderInstanceId = target.provider.instanceId; + return { environmentId: target.environmentId, input: { scope: target.scope, instanceId } }; +} + +/** + * The rows a picker, composer, or message surface should render. + * + * Snapshot-mode providers always use their snapshot skills. A project-mode + * provider uses its last successful inventory, which the query layer keeps + * available while a refresh is in flight; before the first response and after + * a failure it falls back to the snapshot array, so a broken RPC degrades to + * today's behavior rather than an empty menu. + */ +export function selectProviderSkills(input: { + readonly provider: ServerProvider | null; + readonly inventory: ServerProviderSkillInventoryResult | null; +}): ReadonlyArray { + if (input.provider === null) { + return NO_SKILLS; + } + if (usesProjectSkillInventory(input.provider) && input.inventory !== null) { + return input.inventory.skills; + } + return input.provider.skills; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index edd1893f739..45de093be9b 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -630,6 +630,18 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetResourceTelemetryHistory, staleTimeMs: 5_000, }), + // Requested while a scoped surface displays a project-inventory provider. + // Each cold subscription costs a recursive scan on the + // environment server and a round trip for remote clients. The family key + // (environment + scope + instance) makes concurrent consumers share one + // request; the short stale window absorbs rapid resubscriptions without + // making "reopen to see a removed skill disappear" untrue. + skillInventory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:providers:skill-inventory", + tag: WS_METHODS.providersSkillInventory, + staleTimeMs: 5_000, + idleTtlMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 400011f8843..5e65844a944 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -124,6 +124,9 @@ import { import { ServerConfigStreamEvent, ServerConfig, + ServerProviderSkillInventoryError, + ServerProviderSkillInventoryInput, + ServerProviderSkillInventoryResult, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -224,6 +227,7 @@ export const WS_METHODS = { serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", + providersSkillInventory: "providers.skillInventory", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", serverUpsertKeybinding: "server.upsertKeybinding", @@ -307,6 +311,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), }); +export const WsProvidersSkillInventoryRpc = Rpc.make(WS_METHODS.providersSkillInventory, { + payload: ServerProviderSkillInventoryInput, + success: ServerProviderSkillInventoryResult, + error: Schema.Union([ServerProviderSkillInventoryError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { payload: ServerSelfUpdateInput, success: ServerSelfUpdateResult, @@ -788,6 +798,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsProvidersSkillInventoryRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 078e9fcbf33..65d78d2c2b1 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,7 +1,12 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts"; +import { + ServerConfig, + ServerProvider, + ServerUpsertKeybindingResult, + usesProjectSkillInventory, +} from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); @@ -27,8 +32,29 @@ describe("ServerProvider", () => { expect(parsed.skills).toEqual([]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); + expect(parsed.skillInventoryMode).toBeUndefined(); + expect(usesProjectSkillInventory(parsed)).toBe(false); }); + it("treats an explicit project inventory mode as dynamic", () => { + const parsed = decodeServerProvider({ + instanceId: "cursor", + driver: "cursor", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + skillInventoryMode: "project", + }); + + expect(usesProjectSkillInventory(parsed)).toBe(true); + }); +}); + +describe("ServerProvider compatibility", () => { it("defaults one-click update support when decoding older advisory snapshots", () => { const parsed = decodeServerProvider({ instanceId: "codex", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 334116794f8..a734656045d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -95,6 +95,17 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +/** + * Where a provider's skill inventory comes from. + * + * Absent means the environment-wide `ServerProvider.skills` array is the + * whole inventory. `"project"` means inventory depends on the directory the + * agent will run in, so it must be requested per thread or project through + * `providers.skillInventory`. + */ +export const ServerProviderSkillInventoryMode = Schema.Literal("project"); +export type ServerProviderSkillInventoryMode = typeof ServerProviderSkillInventoryMode.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -191,6 +202,11 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + // Absent means `"snapshot"` — `skills` above is the whole inventory. A + // `"project"` provider computes its inventory per working directory, so + // clients must ask `providers.skillInventory` for a thread or project + // instead of reading `skills`. + skillInventoryMode: Schema.optional(ServerProviderSkillInventoryMode), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); @@ -208,6 +224,14 @@ export type ServerProviders = typeof ServerProviders.Type; export const isProviderAvailable = (snapshot: ServerProvider): boolean => snapshot.availability !== "unavailable"; +/** + * Whether this provider's `$` inventory must be requested per thread/project + * rather than read from `snapshot.skills`. Absent means snapshot mode, so + * existing producers keep their zero-round-trip picker. + */ +export const usesProjectSkillInventory = (snapshot: ServerProvider): boolean => + snapshot.skillInventoryMode === "project"; + export const ServerObservability = Schema.Struct({ logsDirectoryPath: TrimmedNonEmptyString, localTracingEnabled: Schema.Boolean, @@ -582,6 +606,41 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( + "ServerProviderSkillInventoryError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Provider skill inventory failed: ${this.reason}`; + } +} + export const ServerSelfUpdateInput = Schema.Struct({ /** Exact npm version of the `t3` package to install (never a dist-tag, so the server and the acknowledging client agree on what was requested). */ From cc890546ca485ef3e269591fc4be2982eab4cf85 Mon Sep 17 00:00:00 2001 From: Julio Reyes Date: Sat, 1 Aug 2026 02:43:07 -0300 Subject: [PATCH 2/4] refactor(server): inline inventory read errors --- .../src/provider/ProviderSkillInventory.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/ProviderSkillInventory.ts b/apps/server/src/provider/ProviderSkillInventory.ts index ea905e591f7..834d34cfb6f 100644 --- a/apps/server/src/provider/ProviderSkillInventory.ts +++ b/apps/server/src/provider/ProviderSkillInventory.ts @@ -27,10 +27,6 @@ import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; -/** Wrap a read-model failure in the RPC's typed inventory error. */ -const repositoryFailure = (reason: string) => (cause: unknown) => - new ServerProviderSkillInventoryError({ reason, cause }); - /** * Resolve the inventory scope to the directory a provider session for that * scope would be started in. @@ -46,9 +42,15 @@ const resolveInventoryCwd = Effect.fn("resolveInventoryCwd")(function* ( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; if (scope.kind === "project") { - const project = yield* projectionSnapshotQuery - .getProjectShellById(scope.projectId) - .pipe(Effect.mapError(repositoryFailure("Failed to read the project read model."))); + const project = yield* projectionSnapshotQuery.getProjectShellById(scope.projectId).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillInventoryError({ + reason: "Failed to read the project read model.", + cause, + }), + ), + ); if (Option.isNone(project)) { return yield* new ServerProviderSkillInventoryError({ reason: `Unknown project '${scope.projectId}'.`, @@ -57,18 +59,30 @@ const resolveInventoryCwd = Effect.fn("resolveInventoryCwd")(function* ( return project.value.workspaceRoot; } - const thread = yield* projectionSnapshotQuery - .getThreadShellById(scope.threadId) - .pipe(Effect.mapError(repositoryFailure("Failed to read the thread read model."))); + const thread = yield* projectionSnapshotQuery.getThreadShellById(scope.threadId).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillInventoryError({ + reason: "Failed to read the thread read model.", + cause, + }), + ), + ); if (Option.isNone(thread)) { return yield* new ServerProviderSkillInventoryError({ reason: `Unknown thread '${scope.threadId}'.`, }); } - const project = yield* projectionSnapshotQuery - .getProjectShellById(thread.value.projectId) - .pipe(Effect.mapError(repositoryFailure("Failed to read the project read model."))); + const project = yield* projectionSnapshotQuery.getProjectShellById(thread.value.projectId).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillInventoryError({ + reason: "Failed to read the project read model.", + cause, + }), + ), + ); const cwd = resolveThreadWorkspaceCwd({ thread: thread.value, From e4733d6fbdda1c1399f737cdff6b451938a00760 Mon Sep 17 00:00:00 2001 From: Julio Reyes Date: Sat, 1 Aug 2026 03:17:58 -0300 Subject: [PATCH 3/4] feat(server): expand provider skill inventory discovery - Discover skills from Claude, Codex, Agents, and Cursor roots - Resolve archived thread workspaces and protect against symlink escapes --- .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 27 ++- .../Layers/ProjectionSnapshotQuery.ts | 51 +++++- .../Services/ProjectionSnapshotQuery.ts | 15 ++ .../project/ProjectSetupScriptRunner.test.ts | 1 + .../src/provider/Drivers/CursorSkills.test.ts | 160 ++++++++++++++---- .../src/provider/Drivers/CursorSkills.ts | 96 +++++++++-- .../Layers/ProviderSessionReaper.test.ts | 1 + .../provider/ProviderSkillInventory.test.ts | 61 +++++-- .../src/provider/ProviderSkillInventory.ts | 44 ++--- apps/server/src/serverRuntimeStartup.test.ts | 4 + docs/internals/providers.md | 9 +- 13 files changed, 387 insertions(+), 88 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index fe093c451e2..8586852a2f1 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -88,6 +88,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -197,6 +198,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), @@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), @@ -350,6 +353,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), @@ -404,6 +408,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 9ffe50d1341..cbc97a2f32b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -198,6 +198,7 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6fe7f831a03..2861347288d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -532,7 +532,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'full-access', 'default', NULL, - NULL, + '/tmp/archive-worktree', NULL, NULL, 0, @@ -569,6 +569,31 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { [ThreadId.make("thread-archived")], ); assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); + + const archivedThreadId = ThreadId.make("thread-archived"); + const archivedThread = yield* snapshotQuery.getThreadShellById(archivedThreadId); + assert.equal(archivedThread._tag, "None"); + + const archivedWorkspaceContext = + yield* snapshotQuery.getThreadWorkspaceContextById(archivedThreadId); + assert.equal(archivedWorkspaceContext._tag, "Some"); + if (archivedWorkspaceContext._tag === "Some") { + assert.equal(archivedWorkspaceContext.value.workspaceRoot, "/tmp/archive-test"); + assert.equal(archivedWorkspaceContext.value.worktreePath, "/tmp/archive-worktree"); + } + + yield* sql` + UPDATE projection_projects + SET deleted_at = '2026-04-06T00:00:08.000Z' + WHERE project_id = 'project-archive-test' + `; + const contextWithoutActiveProject = + yield* snapshotQuery.getThreadWorkspaceContextById(archivedThreadId); + assert.equal(contextWithoutActiveProject._tag, "Some"); + if (contextWithoutActiveProject._tag === "Some") { + assert.equal(contextWithoutActiveProject.value.workspaceRoot, null); + assert.equal(contextWithoutActiveProject.value.worktreePath, "/tmp/archive-worktree"); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4dcc43913c4..131767f8a30 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -56,8 +56,9 @@ import { ProjectionSnapshotQuery, type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, - type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, + type ProjectionThreadCheckpointContext, + type ProjectionThreadWorkspaceContext, } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); @@ -133,6 +134,12 @@ const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, }); +const ProjectionThreadWorkspaceContextRowSchema = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + workspaceRoot: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, @@ -849,6 +856,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getThreadWorkspaceContextRow = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadWorkspaceContextRowSchema, + execute: ({ threadId }) => + sql` + SELECT + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + projects.workspace_root AS "workspaceRoot", + threads.worktree_path AS "worktreePath" + FROM projection_threads AS threads + LEFT JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + AND projects.deleted_at IS NULL + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + LIMIT 1 + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -1952,6 +1979,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.map(Option.map((row) => row.threadId)), ); + const getThreadWorkspaceContextById: ProjectionSnapshotQueryShape["getThreadWorkspaceContextById"] = + (threadId) => + getThreadWorkspaceContextRow({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadWorkspaceContextById:query", + "ProjectionSnapshotQuery.getThreadWorkspaceContextById:decodeRow", + ), + ), + Effect.map( + Option.map( + (row): ProjectionThreadWorkspaceContext => ({ + threadId: row.threadId, + projectId: row.projectId, + workspaceRoot: row.workspaceRoot, + worktreePath: row.worktreePath, + }), + ), + ), + ); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -2268,6 +2316,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getThreadWorkspaceContextById, getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 64138fb7559..1e8b38f6338 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -36,6 +36,13 @@ export interface ProjectionSnapshotSequence { readonly snapshotSequence: number; } +export interface ProjectionThreadWorkspaceContext { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly workspaceRoot: string | null; + readonly worktreePath: string | null; +} + export interface ProjectionThreadCheckpointContext { readonly threadId: ThreadId; readonly projectId: ProjectId; @@ -139,6 +146,14 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the workspace context for a non-deleted thread, including archived + * threads. The project root is absent when the owning project is unavailable. + */ + readonly getThreadWorkspaceContextById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the checkpoint context needed to resolve a single thread diff. */ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0..ddcec7ae649 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts index feb10681501..9212c4a0a54 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.test.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -42,54 +42,79 @@ const makeFixture = Effect.fn(function* () { home, workspace, environment: { HOME: home, USERPROFILE: home } satisfies NodeJS.ProcessEnv, + userClaude: path.join(home, ".claude", "skills"), + userCodex: path.join(home, ".codex", "skills"), userCursor: path.join(home, ".cursor", "skills"), userAgents: path.join(home, ".agents", "skills"), + projectClaude: path.join(workspace, ".claude", "skills"), + projectCodex: path.join(workspace, ".codex", "skills"), projectCursor: path.join(workspace, ".cursor", "skills"), projectAgents: path.join(workspace, ".agents", "skills"), }; }); it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { - it.effect("merges all four roots with project and .cursor winning collisions", () => + it.effect("merges all eight roots with project and .cursor winning collisions", () => Effect.gen(function* () { const path = yield* Path.Path; const fixture = yield* makeFixture(); - yield* writeSkill( - path.join(fixture.userAgents, "user-agents-only"), - frontmatter("user-agents-only", "From the user .agents root."), - ); - yield* writeSkill( - path.join(fixture.userCursor, "user-cursor-only"), - frontmatter("user-cursor-only", "From the user .cursor root."), - ); + for (const [root, name, description] of [ + [fixture.userClaude, "user-claude-only", "From the user .claude root."], + [fixture.userCodex, "user-codex-only", "From the user .codex root."], + [fixture.userAgents, "user-agents-only", "From the user .agents root."], + [fixture.userCursor, "user-cursor-only", "From the user .cursor root."], + [fixture.projectClaude, "project-claude-only", "From the project .claude root."], + [fixture.projectCodex, "project-codex-only", "From the project .codex root."], + ] as const) { + yield* writeSkill(path.join(root, name), frontmatter(name, description)); + } + // Same name in every root: the highest-precedence one must win, and the // row must appear exactly once. - yield* writeSkill( - path.join(fixture.userAgents, "everywhere"), - frontmatter("everywhere", "user agents"), - ); - yield* writeSkill( - path.join(fixture.userCursor, "everywhere"), - frontmatter("everywhere", "user cursor"), - ); - yield* writeSkill( - path.join(fixture.projectAgents, "everywhere"), - frontmatter("everywhere", "project agents"), - ); - yield* writeSkill( - path.join(fixture.projectCursor, "everywhere"), - frontmatter("everywhere", "project cursor"), - ); + for (const [root, description] of [ + [fixture.userClaude, "user claude"], + [fixture.userCodex, "user codex"], + [fixture.userAgents, "user agents"], + [fixture.userCursor, "user cursor"], + [fixture.projectClaude, "project claude"], + [fixture.projectCodex, "project codex"], + [fixture.projectAgents, "project agents"], + [fixture.projectCursor, "project cursor"], + ] as const) { + yield* writeSkill(path.join(root, "everywhere"), frontmatter("everywhere", description)); + } + // Same scope, different roots: `.cursor` beats `.agents`. - yield* writeSkill( - path.join(fixture.projectAgents, "same-scope"), - frontmatter("same-scope", "project agents"), - ); - yield* writeSkill( - path.join(fixture.projectCursor, "same-scope"), - frontmatter("same-scope", "project cursor"), - ); + for (const [root, description] of [ + [fixture.projectClaude, "project claude"], + [fixture.projectCodex, "project codex"], + [fixture.projectAgents, "project agents"], + [fixture.projectCursor, "project cursor"], + ] as const) { + yield* writeSkill(path.join(root, "same-scope"), frontmatter("same-scope", description)); + } + + for (const [name, roots] of [ + [ + "compat-precedence", + [ + [fixture.projectClaude, "project claude"], + [fixture.projectCodex, "project codex"], + ], + ], + [ + "portable-precedence", + [ + [fixture.projectCodex, "project codex"], + [fixture.projectAgents, "project agents"], + ], + ], + ] as const) { + for (const [root, description] of roots) { + yield* writeSkill(path.join(root, name), frontmatter(name, description)); + } + } // Organizational category directory nested inside a root. yield* writeSkill( path.join(fixture.projectCursor, "shipping", "land-it"), @@ -101,10 +126,16 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { assert.deepEqual( skills.map((skill) => [skill.name, skill.scope, skill.description]), [ + ["compat-precedence", "project", "project codex"], ["everywhere", "project", "project cursor"], ["land-it", "project", "Nested under a category directory."], + ["portable-precedence", "project", "project agents"], + ["project-claude-only", "project", "From the project .claude root."], + ["project-codex-only", "project", "From the project .codex root."], ["same-scope", "project", "project cursor"], ["user-agents-only", "user", "From the user .agents root."], + ["user-claude-only", "user", "From the user .claude root."], + ["user-codex-only", "user", "From the user .codex root."], ["user-cursor-only", "user", "From the user .cursor root."], ], ); @@ -229,6 +260,37 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { }), ); + it.effect("ignores heading-like lines inside fenced code blocks", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + + yield* writeSkill( + path.join(fixture.projectCursor, "backtick-fence"), + ["```sh", "# install command", "```", "", "# Real heading"].join("\n"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "tilde-fence"), + ["~~~md", "# rendered as code", "~~~~", "", "## Visible heading"].join("\n"), + ); + yield* writeSkill( + path.join(fixture.projectCursor, "unclosed-fence"), + ["```sh", "# remains code through end of file"].join("\n"), + ); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [ + ["backtick-fence", "Real heading"], + ["tilde-fence", "Visible heading"], + ["unclosed-fence", undefined], + ], + ); + }), + ); + it.effect("skips only what it cannot read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -353,6 +415,38 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { }), ); + it.effect("reads a symlinked SKILL.md only when its target stays inside the root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture(); + const inRootTarget = path.join(fixture.projectCursor, "in-root-target.md"); + const outsideTarget = path.join(fixture.tempDir, "outside-target.md"); + const inRootSkill = path.join(fixture.projectCursor, "in-root-link"); + const escapedSkill = path.join(fixture.projectCursor, "escaped-file-link"); + + yield* fs.makeDirectory(inRootSkill, { recursive: true }); + yield* fs.makeDirectory(escapedSkill, { recursive: true }); + yield* fs.writeFileString( + inRootTarget, + frontmatter("in-root-link", "Read through an in-root file symlink."), + ); + yield* fs.writeFileString( + outsideTarget, + frontmatter("escaped-file-link", "Must not escape the skill root."), + ); + yield* fs.symlink(inRootTarget, path.join(inRootSkill, "SKILL.md")); + yield* fs.symlink(outsideTarget, path.join(escapedSkill, "SKILL.md")); + + const skills = yield* discoverCursorSkills(fixture.workspace, fixture.environment); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [["in-root-link", "Read through an in-root file symlink."]], + ); + }), + ); + it.effect("bounds directories visited even when none contain skills", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index b49f51032e1..429b7d0dfdd 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -1,11 +1,12 @@ /** * CursorSkills — filesystem discovery of Cursor Agent skills for the `$` picker. * - * Cursor loads skills from a user-scope and a project-scope pair of roots, one - * directory per skill with a `SKILL.md`. Unlike Codex, Cursor's ACP surface - * exposes skills only as slash-command metadata mixed in with commands and - * built-ins, without filesystem paths or a stable scope tag, so we scan the - * same locations directly. + * Cursor loads skills from `.cursor`, `.agents`, `.codex`, and `.claude` + * roots at both user and project scope, one directory per skill with a + * `SKILL.md`. Unlike Codex, Cursor's ACP surface exposes skills only as + * slash-command metadata mixed in with commands and built-ins, without + * filesystem paths or a stable scope tag, so we scan the same locations + * directly. * * Discovery is per working directory: two projects in one environment have * different project roots, and a thread running in a worktree resolves to that @@ -73,11 +74,49 @@ export const MAX_DIRECTORIES_PER_ROOT = 2_000; export const MAX_ENTRIES_PER_ROOT = 10_000; const FRONTMATTER_PATTERN = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; -const HEADING_PATTERN = /^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/m; +const FENCE_PATTERN = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/; +const HEADING_PATTERN = /^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/; const UTF8_BOM = "\ufeff"; const utf8Decoder = new TextDecoder("utf-8"); +/** Find the first ATX heading that Markdown renders outside a fenced code block. */ +function firstBodyHeading(body: string): string | undefined { + let fence: { readonly marker: "`" | "~"; readonly length: number } | undefined; + + for (const line of body.split(/\r?\n/)) { + const fenceMatch = FENCE_PATTERN.exec(line); + const fenceRun = fenceMatch?.[1]; + + if (fence) { + if ( + fenceRun?.startsWith(fence.marker) === true && + fenceRun.length >= fence.length && + fenceMatch?.[2]?.trim().length === 0 + ) { + fence = undefined; + } + continue; + } + + if (fenceRun) { + const marker = fenceRun[0] as "`" | "~"; + const info = fenceMatch?.[2] ?? ""; + // Backticks in a backtick fence's info string make it plain text under + // CommonMark, while tilde fences have no corresponding restriction. + if (marker === "~" || !info.includes("`")) { + fence = { marker, length: fenceRun.length }; + continue; + } + } + + const headingText = HEADING_PATTERN.exec(line)?.[1]?.trim(); + if (headingText) return headingText; + } + + return undefined; +} + /** * Pull a display description out of `SKILL.md`, preferring frontmatter and * falling back to the body's first heading the way Cursor does. Returns @@ -102,9 +141,7 @@ function parseSkillDescription(contents: string): string | undefined { } } - const heading = HEADING_PATTERN.exec(match ? body.slice(match[0].length) : body); - const headingText = heading?.[1]?.trim(); - return headingText && headingText.length > 0 ? headingText : undefined; + return firstBodyHeading(match ? body.slice(match[0].length) : body); } /** @@ -139,9 +176,10 @@ export function resolveCursorHomeDirectory( } /** - * The four roots Cursor reads, lowest precedence first. Later roots overwrite - * earlier ones by name, so `.cursor` beating `.agents` and project beating - * user both fall out of the ordering. + * The eight roots Cursor reads, lowest precedence first: `.claude`, `.codex`, + * `.agents`, then `.cursor` within each scope. Later roots overwrite earlier + * ones by name, and all project roots follow all user roots so project scope + * wins. */ function buildSkillRoots(input: { readonly path: Path.Path; @@ -151,8 +189,20 @@ function buildSkillRoots(input: { const home = input.path.resolve(input.homeDirectory); const workspace = input.path.resolve(input.cwd); return [ + { directory: input.path.join(home, ".claude", "skills"), boundary: home, scope: "user" }, + { directory: input.path.join(home, ".codex", "skills"), boundary: home, scope: "user" }, { directory: input.path.join(home, ".agents", "skills"), boundary: home, scope: "user" }, { directory: input.path.join(home, ".cursor", "skills"), boundary: home, scope: "user" }, + { + directory: input.path.join(workspace, ".claude", "skills"), + boundary: workspace, + scope: "project", + }, + { + directory: input.path.join(workspace, ".codex", "skills"), + boundary: workspace, + scope: "project", + }, { directory: input.path.join(workspace, ".agents", "skills"), boundary: workspace, @@ -258,7 +308,13 @@ const collectSkillsInRoot = Effect.fn("collectSkillsInRoot")(function* (input: { const skillPath = path.join(directory, "SKILL.md"); if (entries.includes("SKILL.md")) { - const contents = yield* readSkillMetadata(path.join(resolved, "SKILL.md")); + const resolvedSkillPath = yield* fileSystem + .realPath(path.join(resolved, "SKILL.md")) + .pipe(Effect.orElseSucceed(() => undefined)); + const contents = + resolvedSkillPath !== undefined && isWithin(resolvedSkillPath, resolvedRoot) + ? yield* readSkillMetadata(resolvedSkillPath) + : undefined; // Cursor names a skill after the directory that holds its SKILL.md, // ignoring any frontmatter `name`. const name = path.basename(directory).trim(); @@ -311,11 +367,17 @@ export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* cwd, }); - // Lowest precedence first, so a later `set` is the more specific root - // winning: project over user, and `.cursor` over `.agents` within a scope. + // Root scans are independent filesystem work. `Effect.forEach` preserves + // input order, so bounded concurrency does not change precedence merging. + const inventories = yield* Effect.forEach(roots, (root) => collectSkillsInRoot({ root }), { + concurrency: 4, + }); + + // Lowest precedence first, so later roots win: project over user, and + // `.cursor` over `.agents`, `.codex`, and `.claude` within a scope. const skillsByName = new Map(); - for (const root of roots) { - for (const skill of yield* collectSkillsInRoot({ root })) { + for (const inventory of inventories) { + for (const skill of inventory) { skillsByName.set(skill.name, skill); } } diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index f3f4ca39d47..0f03d3c9227 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -202,6 +202,7 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: (threadId) => diff --git a/apps/server/src/provider/ProviderSkillInventory.test.ts b/apps/server/src/provider/ProviderSkillInventory.test.ts index 255ff61ae8a..4c0fcf5aa43 100644 --- a/apps/server/src/provider/ProviderSkillInventory.test.ts +++ b/apps/server/src/provider/ProviderSkillInventory.test.ts @@ -59,17 +59,24 @@ const makeInstance = (input: { */ const projectionLayer = (input: { readonly projects?: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; - readonly thread?: { readonly projectId: ProjectId; readonly worktreePath: string | null }; + readonly threadContext?: { + readonly projectId: ProjectId; + readonly workspaceRoot: string | null; + readonly worktreePath: string | null; + }; }) => Layer.mock(ProjectionSnapshotQuery)({ getProjectShellById: ((projectId: ProjectId) => Effect.succeed( Option.fromNullishOr(input.projects?.find((project) => project.id === projectId)), )) as unknown as ProjectionSnapshotQuery["Service"]["getProjectShellById"], - getThreadShellById: (() => + getThreadWorkspaceContextById: ((threadId: ThreadId) => Effect.succeed( - Option.fromNullishOr(input.thread), - )) as unknown as ProjectionSnapshotQuery["Service"]["getThreadShellById"], + Option.map(Option.fromNullishOr(input.threadContext), (context) => ({ + threadId, + ...context, + })), + )) as unknown as ProjectionSnapshotQuery["Service"]["getThreadWorkspaceContextById"], }); const registryLayer = (instance: ProviderInstance | undefined) => @@ -120,8 +127,11 @@ it.effect("resolves thread scope to the thread's worktree when it has one", () = Layer.mergeAll( registryLayer(makeInstance({ inventory: () => [], observedCwds })), projectionLayer({ - projects: [{ id: PROJECT_ID, workspaceRoot: "/repos/a" }], - thread: { projectId: PROJECT_ID, worktreePath: "/repos/a-worktrees/feature" }, + threadContext: { + projectId: PROJECT_ID, + workspaceRoot: "/repos/a", + worktreePath: "/repos/a-worktrees/feature", + }, }), ), ), @@ -131,6 +141,31 @@ it.effect("resolves thread scope to the thread's worktree when it has one", () = }), ); +it.effect("resolves an archived thread scope to the thread's worktree", () => + Effect.gen(function* () { + const observedCwds: Array = []; + yield* resolveProviderSkillInventory({ + scope: { kind: "thread", threadId: THREAD_ID }, + instanceId: INSTANCE_ID, + }).pipe( + Effect.provide( + Layer.mergeAll( + registryLayer(makeInstance({ inventory: () => [], observedCwds })), + projectionLayer({ + threadContext: { + projectId: PROJECT_ID, + workspaceRoot: "/repos/a", + worktreePath: "/repos/a-worktrees/archived", + }, + }), + ), + ), + ); + + assert.deepEqual(observedCwds, ["/repos/a-worktrees/archived"]); + }), +); + it.effect("resolves a worktree thread even when the project row is missing", () => Effect.gen(function* () { const observedCwds: Array = []; @@ -142,8 +177,11 @@ it.effect("resolves a worktree thread even when the project row is missing", () Layer.mergeAll( registryLayer(makeInstance({ inventory: () => [], observedCwds })), projectionLayer({ - projects: [], - thread: { projectId: PROJECT_ID, worktreePath: "/repos/a-worktrees/feature" }, + threadContext: { + projectId: PROJECT_ID, + workspaceRoot: null, + worktreePath: "/repos/a-worktrees/feature", + }, }), ), ), @@ -164,8 +202,11 @@ it.effect("falls back to the project workspace root for a thread without a workt Layer.mergeAll( registryLayer(makeInstance({ inventory: () => [], observedCwds })), projectionLayer({ - projects: [{ id: PROJECT_ID, workspaceRoot: "/repos/a" }], - thread: { projectId: PROJECT_ID, worktreePath: null }, + threadContext: { + projectId: PROJECT_ID, + workspaceRoot: "/repos/a", + worktreePath: null, + }, }), ), ), diff --git a/apps/server/src/provider/ProviderSkillInventory.ts b/apps/server/src/provider/ProviderSkillInventory.ts index 834d34cfb6f..0537d5ad81a 100644 --- a/apps/server/src/provider/ProviderSkillInventory.ts +++ b/apps/server/src/provider/ProviderSkillInventory.ts @@ -59,34 +59,34 @@ const resolveInventoryCwd = Effect.fn("resolveInventoryCwd")(function* ( return project.value.workspaceRoot; } - const thread = yield* projectionSnapshotQuery.getThreadShellById(scope.threadId).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillInventoryError({ - reason: "Failed to read the thread read model.", - cause, - }), - ), - ); - if (Option.isNone(thread)) { + const threadContext = yield* projectionSnapshotQuery + .getThreadWorkspaceContextById(scope.threadId) + .pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillInventoryError({ + reason: "Failed to read the thread read model.", + cause, + }), + ), + ); + if (Option.isNone(threadContext)) { return yield* new ServerProviderSkillInventoryError({ reason: `Unknown thread '${scope.threadId}'.`, }); } - const project = yield* projectionSnapshotQuery.getProjectShellById(thread.value.projectId).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillInventoryError({ - reason: "Failed to read the project read model.", - cause, - }), - ), - ); - const cwd = resolveThreadWorkspaceCwd({ - thread: thread.value, - projects: Option.isSome(project) ? [project.value] : [], + thread: threadContext.value, + projects: + threadContext.value.workspaceRoot === null + ? [] + : [ + { + id: threadContext.value.projectId, + workspaceRoot: threadContext.value.workspaceRoot, + }, + ], }); if (cwd === undefined) { return yield* new ServerProviderSkillInventoryError({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e..0c7ac53c619 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -92,6 +92,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), @@ -156,6 +157,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), @@ -201,6 +203,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), @@ -252,6 +255,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadWorkspaceContextById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ab8b6f064d0..99c49a2bc9f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -44,10 +44,11 @@ orchestration, contract, or client change is required for the common case. Most drivers publish their skills once, as `ServerProvider.skills` on the status snapshot, and the composer's `$` menu renders that array with no round trip. -Cursor cannot: its skills come from `.cursor/skills` and `.agents/skills` under both the user's home -and the workspace, so the answer depends on the directory the agent will run in — a different project -or a worktree thread has a different inventory. Such a driver implements the optional -`skillInventory` capability on [`ProviderInstance`][adapterdriver]. The provider registry derives +Cursor cannot: its skills come from `.cursor/skills`, `.agents/skills`, `.codex/skills`, and +`.claude/skills` under both the user's home and the workspace, so the answer depends on the directory +the agent will run in — a different project or a worktree thread has a different inventory. Such a +driver implements the optional `skillInventory` capability on +[`ProviderInstance`][adapterdriver]. The provider registry derives `skillInventoryMode: "project"` on every published snapshot, so the capability has one source of truth. Clients see that flag and call the `providers.skillInventory` RPC with a thread or project id instead of reading `skills`. From 6f0278988c77a5ab25fe77788d4387341a9403de Mon Sep 17 00:00:00 2001 From: Julio Reyes Date: Sat, 1 Aug 2026 03:24:08 -0300 Subject: [PATCH 4/4] fix(cursor): collect skills before bounding child inspection - Preserve skills found in wide directories before applying the entry inspection limit - Update coverage for bounded traversal behavior --- .../src/provider/Drivers/CursorSkills.test.ts | 18 +++++++++++------- .../src/provider/Drivers/CursorSkills.ts | 13 +++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts index 9212c4a0a54..adddfea52c0 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.test.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -505,29 +505,30 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { }), ); - it.effect("bounds entry inspection in a wide root containing no directories", () => + it.effect("collects the current skill before bounding child inspection in a wide directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const fixture = yield* makeFixture(); + const wideSkill = path.join(fixture.projectCursor, "wide-skill"); const entryNames = Array.from( { length: MAX_ENTRIES_PER_ROOT + 50 }, (_, index) => `file-${String(index).padStart(5, "0")}.txt`, ); let entriesStatted = 0; - yield* fs.makeDirectory(fixture.projectCursor, { recursive: true }); - const resolvedProjectCursor = yield* fs.realPath(fixture.projectCursor); + yield* writeSkill(wideSkill, frontmatter("wide-skill", "Found before traversal stops.")); + const resolvedWideSkill = yield* fs.realPath(wideSkill); const instrumented = Layer.succeed( FileSystem.FileSystem, FileSystem.FileSystem.of({ ...fs, readDirectory: (directory, options) => - String(directory) === resolvedProjectCursor - ? Effect.succeed(entryNames) + String(directory) === resolvedWideSkill + ? Effect.succeed(["SKILL.md", ...entryNames]) : fs.readDirectory(directory, options), stat: (filePath) => { - if (String(filePath).startsWith(`${resolvedProjectCursor}${path.sep}`)) { + if (String(filePath).startsWith(`${resolvedWideSkill}${path.sep}`)) { entriesStatted += 1; } return fs.stat(filePath); @@ -539,7 +540,10 @@ it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { Effect.provide(instrumented), ); - assert.isEmpty(skills); + assert.deepEqual( + skills.map((skill) => [skill.name, skill.description]), + [["wide-skill", "Found before traversal stops."]], + ); assert.equal(entriesStatted, 0); }), ); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 429b7d0dfdd..a16ff95e818 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -283,11 +283,7 @@ const collectSkillsInRoot = Effect.fn("collectSkillsInRoot")(function* (input: { const walk = (directory: string): Effect.Effect => Effect.gen(function* () { - if ( - collected.length >= MAX_SKILLS_PER_ROOT || - visited.size >= MAX_DIRECTORIES_PER_ROOT || - entriesInspected >= MAX_ENTRIES_PER_ROOT - ) { + if (collected.length >= MAX_SKILLS_PER_ROOT || visited.size >= MAX_DIRECTORIES_PER_ROOT) { return; } @@ -302,9 +298,6 @@ const collectSkillsInRoot = Effect.fn("collectSkillsInRoot")(function* (input: { const entries = yield* fileSystem .readDirectory(resolved) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - const remainingEntries = MAX_ENTRIES_PER_ROOT - entriesInspected; - if (entries.length > remainingEntries) return; - entriesInspected += entries.length; const skillPath = path.join(directory, "SKILL.md"); if (entries.includes("SKILL.md")) { @@ -330,6 +323,10 @@ const collectSkillsInRoot = Effect.fn("collectSkillsInRoot")(function* (input: { } } + const remainingEntries = MAX_ENTRIES_PER_ROOT - entriesInspected; + if (entries.length > remainingEntries) return; + entriesInspected += entries.length; + for (const entry of [...entries].sort()) { if (collected.length >= MAX_SKILLS_PER_ROOT || visited.size >= MAX_DIRECTORIES_PER_ROOT) { return;