diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..f989d56b23df 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -83,6 +83,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + // Reads a thread, its project's workspace root, and that directory's + // skill definitions. Read-only, so it sits with the other read methods. + [WS_METHODS.providersWorkspaceSkills]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..58ff330e749f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -54,11 +54,15 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +// One entry per workspace the user has open. Small enough to bound CLI +// spawns, large enough that switching between projects does not re-probe. +const CAPABILITIES_PROBE_CACHE_CAPACITY = 16; function isClaudeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); @@ -151,21 +155,27 @@ export const ClaudeDriver: ProviderDriver = { const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); - // Per-instance capabilities cache: keyed on binary + resolved HOME so - // account-specific probes never share auth metadata across instances. + // Per-instance capabilities cache, keyed by working directory. + // + // This cache lives in one instance's scope, so binary path and resolved + // HOME are already constant here — they cannot leak account metadata + // across instances. The only thing that varies is the cwd, and it + // genuinely does vary: the CLI's init handshake reports PROJECT-scoped + // slash commands, so a probe run from the server's own cwd sees only the + // built-ins. Keying on cwd lets each workspace get its own answer while + // still costing one CLI spawn per workspace per TTL. const capabilitiesProbeCache = yield* Cache.make({ - capacity: 1, + capacity: CAPABILITIES_PROBE_CACHE_CAPACITY, timeToLive: CAPABILITIES_PROBE_TTL, - lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + lookup: (probeCwd: string) => + probeClaudeCapabilities(effectiveConfig, processEnv, probeCwd).pipe( Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); const checkProvider = checkClaudeProviderStatus( effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + () => Cache.get(capabilitiesProbeCache, cwd), processEnv, cwd, ).pipe( @@ -216,6 +226,26 @@ export const ClaudeDriver: ProviderDriver = { snapshot, adapter, textGeneration, + // Project-scoped skills. The snapshot's own `skills` are scanned once + // against `ServerConfig.cwd`, which a packaged build sets to the home + // directory, so it can never see a project's `.claude/skills`. This is + // a filesystem scan, not a CLI probe, so it is cheap enough to run per + // request and needs no cache. + discoverSkillsForCwd: (skillsCwd: string) => + discoverClaudeSkills(effectiveConfig, skillsCwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + // Slash commands come from the CLI's init handshake, so this spawns a + // process. It goes through the same cwd-keyed cache the snapshot probe + // uses, which bounds it to one spawn per workspace per TTL. A failed + // probe resolves empty so the caller falls back to the snapshot rather + // than surfacing an error in a picker. + discoverSlashCommandsForCwd: (commandsCwd: string) => + Cache.get(capabilitiesProbeCache, commandsCwd).pipe( + Effect.map((capabilities) => capabilities?.slashCommands ?? []), + Effect.orElseSucceed(() => []), + ), } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..c6494601f892 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -27,6 +27,8 @@ import { ProviderDriverKind, type ProviderInstanceId, type ServerProvider, + type ServerProviderSkill, + type ServerProviderSlashCommand, type ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -508,6 +510,33 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const discoverSkillsForInstance = Effect.fn("discoverSkillsForInstance")(function* ( + instanceId: ProviderInstanceId, + cwd: string, + ) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === instanceId, + ); + // Not live, or a driver with no directory-scoped skill concept: resolve + // empty so the caller falls back to the snapshot instead of failing. + if (!instance?.discoverSkillsForCwd) { + return [] as ReadonlyArray; + } + return yield* instance.discoverSkillsForCwd(cwd); + }); + + const discoverSlashCommandsForInstance = Effect.fn("discoverSlashCommandsForInstance")( + function* (instanceId: ProviderInstanceId, cwd: string) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === instanceId, + ); + if (!instance?.discoverSlashCommandsForCwd) { + return [] as ReadonlyArray; + } + return yield* instance.discoverSlashCommandsForCwd(cwd); + }, + ); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -711,6 +740,8 @@ export const ProviderRegistryLive = Layer.effect( refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, + discoverSkillsForInstance, + discoverSlashCommandsForInstance, setProviderMaintenanceActionState, get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4..8b41076da7bd 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,8 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderSkill, + ServerProviderSlashCommand, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -71,6 +73,37 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + /** + * Skills visible from a specific workspace root. + * + * `snapshot.skills` is machine-scoped: it is produced once per instance + * against the server's own cwd, which for a packaged desktop build is the + * user's home directory. Skills are project-scoped, so that snapshot can + * only ever report the user-scope ones. Callers that know which project + * they are asking about (a thread's worktree or its project's workspace + * root) use this instead, and drivers that can enumerate skills per + * directory implement it. + * + * Optional: a driver that has no directory-scoped skill concept simply + * omits it and callers fall back to the snapshot. + */ + readonly discoverSkillsForCwd?: ( + cwd: string, + ) => Effect.Effect>; + /** + * Slash commands visible from a specific workspace root. + * + * Same scoping problem as `discoverSkillsForCwd`, different source: these + * come from the agent CLI's own init handshake, which reports PROJECT-scoped + * commands. `snapshot.slashCommands` is probed once against the server's cwd + * and therefore lists only the CLI's built-ins. + * + * Unlike the skills scan this spawns the CLI, so implementations should + * cache per directory rather than probe per call. + */ + readonly discoverSlashCommandsForCwd?: ( + cwd: string, + ) => Effect.Effect>; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b303381..2135b062856b 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,6 +10,8 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderSkill, + ServerProviderSlashCommand, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -57,6 +59,36 @@ export interface ProviderRegistryShape { provider: ProviderDriverKind, ) => Effect.Effect; + /** + * Skills one live instance can see from a specific workspace root. + * + * `ServerProvider.skills` is machine-scoped — scanned once against the + * server's own cwd, which a packaged desktop build sets to the home + * directory — so it reports user-scope skills only. Callers holding a + * project context ask here instead. + * + * Resolves to an empty array when the instance is not live or its driver + * has no directory-scoped skill concept; callers then fall back to the + * snapshot rather than treating it as an error. + */ + readonly discoverSkillsForInstance: ( + instanceId: ProviderInstanceId, + cwd: string, + ) => Effect.Effect>; + + /** + * Slash commands one live instance can see from a specific workspace root. + * + * Same scoping story as `discoverSkillsForInstance`, but sourced from the + * agent CLI's init handshake rather than the filesystem, so it may spawn a + * process. Resolves empty when the instance is not live or its driver cannot + * enumerate commands per directory. + */ + readonly discoverSlashCommandsForInstance: ( + instanceId: ProviderInstanceId, + cwd: string, + ) => Effect.Effect>; + /** * Apply volatile maintenance-action state to one configured instance. * This state is never persisted to disk. Today only update actions are diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 641c9b52e56c..9cbabb52d962 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -191,6 +191,8 @@ function makeRegistry( refreshInstance: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), + discoverSkillsForInstance: () => Effect.succeed([]), + discoverSlashCommandsForInstance: () => Effect.succeed([]), setProviderMaintenanceActionState, streamChanges: Stream.empty, }; diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b059009..f402c280c912 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -13,6 +13,8 @@ export const makeProviderRegistryMock = ( refreshInstance: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), + discoverSkillsForInstance: () => Effect.succeed([]), + discoverSlashCommandsForInstance: () => Effect.succeed([]), setProviderMaintenanceActionState: () => Effect.succeed(providers), streamChanges: Stream.empty, }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..4ee4076bdb14 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1934,6 +1934,57 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.providersWorkspaceSkills]: (input) => + observeRpcEffect( + WS_METHODS.providersWorkspaceSkills, + Effect.gen(function* () { + // The project root is the base; a persisted thread's worktree + // overrides it. Keyed on the project rather than the thread so a + // DRAFT thread still resolves — it has no server-side row yet, + // and that is exactly when the skill picker matters most. + const project = yield* projectionSnapshotQuery + .getProjectShellById(input.projectId) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load project for skill discovery", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new OrchestrationGetSnapshotError({ + message: `Unknown project ${input.projectId}`, + }); + } + // An unknown thread id is not an error: a draft thread has no row. + const worktreePath = input.threadId + ? yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.map((thread) => + Option.isSome(thread) ? thread.value.worktreePath : null, + ), + Effect.orElseSucceed(() => null), + ) + : null; + const workspaceRoot = worktreePath ?? project.value.workspaceRoot; + // Run both against the same resolved root. The skills scan is + // filesystem-only; the slash-command probe may spawn the CLI but + // is cached per directory by the driver. + const [skills, slashCommands] = yield* Effect.all( + [ + providerRegistry.discoverSkillsForInstance(input.instanceId, workspaceRoot), + providerRegistry.discoverSlashCommandsForInstance( + input.instanceId, + workspaceRoot, + ), + ], + { concurrency: 2 }, + ); + return { workspaceRoot, skills, slashCommands }; + }), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8dcb6b8ed932..1a4364b010fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -239,6 +239,7 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; +import { providerSkillsEnvironment } from "../state/providerSkills"; import { useEnvironmentQuery } from "../state/query"; import { primaryServerAvailableEditorsAtom, @@ -2720,6 +2721,38 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + // Skills for the `$` picker, resolved against this thread's workspace root. + // `activeProviderStatus.skills` is machine-scoped: the server scans it once + // per provider instance against its own cwd, which a packaged desktop build + // sets to the user's home directory, so it reports user-scope skills only and + // is empty on a machine that keeps none there. Falling back to it keeps the + // picker working against a server that predates this RPC. + const workspaceCapabilitiesQuery = useEnvironmentQuery( + activeThread && activeProject && activeProviderInstanceId + ? providerSkillsEnvironment.workspaceSkills({ + environmentId: activeThread.environmentId, + input: { + projectId: activeProject.id, + instanceId: activeProviderInstanceId, + // Only a persisted thread has a server-side row; a draft has none, + // and the project root is the right answer for it anyway. + ...(activeServerThread ? { threadId: activeServerThread.id } : {}), + }, + }) + : null, + ); + const activeSkills = + workspaceCapabilitiesQuery.data?.skills ?? + activeProviderStatus?.skills ?? + EMPTY_PROVIDER_SKILLS; + // Same story for `/`: the snapshot's slashCommands come from a probe run + // against the server's own cwd, so they are the CLI's built-ins only. The + // workspace-scoped result adds the project's own commands. + const activeSlashCommands = + workspaceCapabilitiesQuery.data?.slashCommands && + workspaceCapabilitiesQuery.data.slashCommands.length > 0 + ? workspaceCapabilitiesQuery.data.slashCommands + : undefined; const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -6534,7 +6567,7 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={activeSkills} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerOverlayHeight} @@ -6661,6 +6694,8 @@ function ChatViewContent(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + workspaceSkills={activeSkills} + workspaceSlashCommands={activeSlashCommands} activeProjectDefaultModelSelection={ activeProject?.defaultModelSelection } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f06a9658225f..46fdc15c5767 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,6 +9,8 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderSkill, + ServerProviderSlashCommand, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -526,6 +528,9 @@ export interface ChatComposerHandle { // Props // -------------------------------------------------------------------------- +const EMPTY_COMPOSER_SKILLS: ReadonlyArray = []; +const EMPTY_COMPOSER_SLASH_COMMANDS: ReadonlyArray = []; + export interface ChatComposerProps { composerDraftTarget: ScopedThreadRef | DraftId; environmentId: EnvironmentId; @@ -584,6 +589,19 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + /** + * Skills for the active workspace. `providerStatuses[].skills` is + * machine-scoped — the server scans it once per provider instance against + * its own cwd — so it reports user-scope skills only. Falls back to the + * snapshot when absent. + */ + workspaceSkills?: ReadonlyArray | undefined; + /** + * Slash commands for the active workspace. `providerStatuses[].slashCommands` + * is probed once against the server's own cwd and therefore lists only the + * agent CLI's built-ins. Falls back to the snapshot when absent. + */ + workspaceSlashCommands?: ReadonlyArray | undefined; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -676,6 +694,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, lockedProvider, providerStatuses, + workspaceSkills, + workspaceSlashCommands, activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadActivities, @@ -890,6 +910,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], ); + // Prefer workspace-scoped capabilities; fall back to the machine-scoped + // snapshot so an older server still populates the pickers. + const composerSkills = useMemo>( + () => + workspaceSkills && workspaceSkills.length > 0 + ? workspaceSkills + : (selectedProviderStatus?.skills ?? EMPTY_COMPOSER_SKILLS), + [workspaceSkills, selectedProviderStatus], + ); const composerPromptInjectionState = useMemo( () => getComposerPromptInjectionState(prompt), @@ -1107,16 +1136,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ] as const) : []), ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( - (command) => ({ - id: `provider-slash-command:${selectedProvider}:${command.name}`, - type: "provider-slash-command" as const, - provider: selectedProvider, - command, - label: `/${command.name}`, - description: command.description ?? command.input?.hint ?? "Run provider command", - }), - ); + const providerSlashCommandItems = ( + workspaceSlashCommands ?? + selectedProviderStatus?.slashCommands ?? + EMPTY_COMPOSER_SLASH_COMMANDS + ).map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); const query = composerTrigger.query.trim().toLowerCase(); const skillItems = (selectedProviderStatus?.skills ?? []) .filter((skill) => skill.enabled) @@ -1139,22 +1170,22 @@ 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 []; }, [ + composerSkills, + workspaceSlashCommands, composerTrigger, planModeUiEnabled, selectedProvider, @@ -3227,7 +3258,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/state/providerSkills.ts b/apps/web/src/state/providerSkills.ts new file mode 100644 index 000000000000..2c2ba8b4f9fc --- /dev/null +++ b/apps/web/src/state/providerSkills.ts @@ -0,0 +1,6 @@ +import { createProviderSkillsEnvironmentAtoms } from "@t3tools/client-runtime/state/providerSkills"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const providerSkillsEnvironment = + createProviderSkillsEnvironmentAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f75be5bc44bb..c60f4ca019bb 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -95,6 +95,10 @@ "types": "./src/state/projectGrouping.ts", "default": "./src/state/projectGrouping.ts" }, + "./state/providerSkills": { + "types": "./src/state/providerSkills.ts", + "default": "./src/state/providerSkills.ts" + }, "./state/relay": { "types": "./src/state/relayDiscovery.ts", "default": "./src/state/relayDiscovery.ts" diff --git a/packages/client-runtime/src/state/providerSkills.ts b/packages/client-runtime/src/state/providerSkills.ts new file mode 100644 index 000000000000..a6e388787381 --- /dev/null +++ b/packages/client-runtime/src/state/providerSkills.ts @@ -0,0 +1,40 @@ +/** + * providerSkills — project-scoped skill discovery for the `$` composer picker. + * + * `ServerProvider.skills` on the provider snapshot is machine-scoped: the + * server scans it once per provider instance against its own cwd, which a + * packaged desktop build sets to the user's home directory. Skills are + * project-scoped, so that snapshot reports user-scope skills only and is empty + * on a machine that keeps none there. + * + * This family asks per project instead, and the server resolves the project's + * workspace root (or a persisted thread's worktree) before scanning. Keying on + * the project means a DRAFT thread resolves too, which is when the picker + * matters most. Consumers fall + * back to the snapshot when the query has no data yet, so the picker degrades + * to the old behaviour rather than to nothing. + * + * @module state/providerSkills + */ +import { WS_METHODS } from "@t3tools/contracts"; + +import type { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function createProviderSkillsEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + workspaceSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:providers:workspace-skills", + tag: WS_METHODS.providersWorkspaceSkills, + // A skill set changes when files change on disk, not per keystroke, so a + // generous stale window keeps the picker instant without pinning the + // scan in memory for threads the user has moved away from. + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), + }; +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..f639d8856207 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { AuthAccessStreamError, @@ -170,6 +171,8 @@ import { ServerProcessResourceHistoryResult, ServerSignalProcessInput, ServerSignalProcessResult, + ServerProviderSkill, + ServerProviderSlashCommand, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server.ts"; @@ -251,6 +254,10 @@ export const WS_METHODS = { previewAutomationRespond: "previewAutomation.respond", previewAutomationFocusHost: "previewAutomation.focusHost", + // Provider capability methods. Project-scoped, unlike the machine-scoped + // provider snapshot: resolved against one thread's workspace root. + providersWorkspaceSkills: "providers.workspaceSkills", + // Server meta serverProbe: "server.probe", serverGetConfig: "server.getConfig", @@ -636,6 +643,48 @@ export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); +/** + * Skills visible to a thread, resolved against its workspace root. + * + * `ServerProvider.skills` on the provider snapshot is machine-scoped: it is + * scanned once per instance against the server's own cwd, which a packaged + * desktop build sets to the user's home directory. Skills are project-scoped, + * so the snapshot can only ever report user-scope ones. Clients showing a + * skill picker for a specific thread ask here instead. + */ +export const ProvidersWorkspaceSkillsInput = Schema.Struct({ + /** Project whose workspace root is scanned. Present for draft threads too. */ + projectId: ProjectId, + /** Provider instance whose skills are wanted. */ + instanceId: ProviderInstanceId, + /** + * Optional: a persisted thread whose worktree overrides the project root. + * A draft thread has no server-side row yet, so callers omit it and still + * get the project's skills — which is exactly when the picker matters most. + */ + threadId: Schema.optional(ThreadId), +}); +export type ProvidersWorkspaceSkillsInput = typeof ProvidersWorkspaceSkillsInput.Type; + +export const ProvidersWorkspaceSkillsResult = Schema.Struct({ + /** Workspace root everything was resolved against, for display and debugging. */ + workspaceRoot: TrimmedNonEmptyString, + skills: Schema.Array(ServerProviderSkill), + /** + * Slash commands the agent CLI reports from that workspace root, which + * include project-scoped commands the machine-scoped snapshot cannot see. + * Empty when the driver cannot enumerate commands per directory. + */ + slashCommands: Schema.Array(ServerProviderSlashCommand), +}); +export type ProvidersWorkspaceSkillsResult = typeof ProvidersWorkspaceSkillsResult.Type; + +export const WsProvidersWorkspaceSkillsRpc = Rpc.make(WS_METHODS.providersWorkspaceSkills, { + payload: ProvidersWorkspaceSkillsInput, + success: ProvidersWorkspaceSkillsResult, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), +}); + export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, @@ -983,6 +1032,7 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso }); export const WsRpcGroup = RpcGroup.make( + WsProvidersWorkspaceSkillsRpc, WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc,