diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..4082acb138d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -36,7 +36,11 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + listCodexProviderSkills, + makePendingCodexProvider, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -199,6 +203,28 @@ export const CodexDriver: ProviderDriver = { ), ); + const listSkills = (cwd: string) => + listCodexProviderSkills({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + cwd, + environment: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.scoped, + Effect.timeout(Duration.seconds(10)), + Effect.catch((error) => + Effect.logWarning("Codex workspace skill discovery failed; using global skills.", { + cwd, + error: String(error), + instanceId, + }).pipe( + Effect.andThen(snapshot.getSnapshot), + Effect.map((provider) => provider.skills), + ), + ), + ); + return { instanceId, driverKind: DRIVER_KIND, @@ -209,6 +235,7 @@ export const CodexDriver: ProviderDriver = { snapshot, adapter, textGeneration, + listSkills, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..4c6660b5d2b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -286,13 +286,16 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { +interface CodexAppServerProbeInput { readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; -}) { +} + +const openCodexAppServerProbe = Effect.fn("openCodexAppServerProbe")(function* ( + input: CodexAppServerProbeInput, +) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -331,22 +334,32 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun Effect.provide(clientContext), ); - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); + const initialize = yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); // Extract the version string after the first '/' in userAgent, up to the next space or the end const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); const version = versionMatch ? versionMatch[1] : undefined; + return { client, version }; +}); + +export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const { client } = yield* openCodexAppServerProbe(input); + const response = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(response, input.cwd); +}); + +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* ( + input: CodexAppServerProbeInput & { readonly customModels?: ReadonlyArray }, +) { + const { client, version } = yield* openCodexAppServerProbe(input); + const accountResponse = yield* client.request("account/read", {}); if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { @@ -371,7 +384,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun account: accountResponse, version, models: appendCustomCodexModels(models, input.customModels ?? []), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd).filter( + (skill) => skill.scope !== "repo", + ), } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 05b1db20aad..97d3f6abd1a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -594,7 +594,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); - it.effect("does not run provider probes during layer construction", () => + it.effect("does not probe at construction and routes cwd-scoped skill discovery", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); const codexInstanceId = ProviderInstanceId.make("codex"); @@ -613,6 +613,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te skills: [], } as const satisfies ServerProvider; const refreshCalls = yield* Ref.make(0); + const skillCwds = yield* Ref.make>([]); + const workspaceSkill = { + name: "project-review", + path: "/tmp/project/.agents/skills/project-review/SKILL.md", + scope: "repo", + enabled: true, + } as const; const instance = { instanceId: codexInstanceId, driverKind: codexDriver, @@ -635,6 +642,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], + listSkills: (cwd: string) => + Ref.update(skillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([workspaceSkill])), } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -664,6 +673,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); + assert.deepStrictEqual( + yield* registry.listSkills({ instanceId: codexInstanceId, cwd: "/tmp/project" }), + [workspaceSkill], + ); + assert.deepStrictEqual(yield* Ref.get(skillCwds), ["/tmp/project"]); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..a90d878d856 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -474,6 +474,20 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const listSkills = Effect.fn("listSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (instance === undefined) { + return []; + } + if (instance.listSkills !== undefined) { + return yield* instance.listSkills(input.cwd); + } + return (yield* instance.snapshot.getSnapshot).skills; + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { @@ -688,6 +702,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + listSkills, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a..2a2d9d0c85e 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"; @@ -71,6 +72,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + /** Resolve the skills visible from a workspace cwd. */ + readonly listSkills?: (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 b7426b30338..e2dcc97ddd1 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, + ServerProviderListSkillsInput, + ServerProviderSkill, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -48,6 +50,11 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** Resolve provider skills using the active workspace cwd. */ + readonly listSkills: ( + input: ServerProviderListSkillsInput, + ) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..d5f4100cc7b 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -187,6 +187,7 @@ function makeRegistry( const registry: ProviderRegistryShape = { getProviders: Ref.get(providersRef), + listSkills: () => Effect.succeed([]), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..dff3834d81c 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -9,6 +9,7 @@ export const makeProviderRegistryMock = ( providers: ReadonlyArray = [], ): ProviderRegistryShape => ({ getProviders: Effect.succeed(providers), + listSkills: () => Effect.succeed([]), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b1f6c1f7251..26f9dcd2772 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4093,6 +4093,47 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.listProviderSkills with workspace cwd", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_work"); + const skills = [ + { + name: "project-review", + path: "/tmp/project/.agents/skills/project-review/SKILL.md", + scope: "repo", + enabled: true, + }, + ] as const; + let receivedInput: { readonly instanceId: ProviderInstanceId; readonly cwd: string } | null = + null; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + listSkills: (input) => + Effect.sync(() => { + receivedInput = input; + return skills; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/tmp/project", + }), + ), + ); + + assert.deepEqual(receivedInput, { instanceId, cwd: "/tmp/project" }); + assert.deepEqual(response.skills, skills); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.removeKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 32dcb8a13d6..16fedb96a9f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -285,6 +285,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], @@ -1251,6 +1252,12 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect( + WS_METHODS.serverListProviderSkills, + providerRegistry.listSkills(input).pipe(Effect.map((skills) => ({ skills }))), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..cb3a19495f0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -58,6 +58,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"; @@ -785,6 +786,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], ); + const providerSkills = useProviderSkills({ + environmentId, + instanceId: selectedInstanceId, + cwd: gitCwd, + }); + const selectedProviderSkills = + providerSkills.data?.skills ?? + selectedProviderStatus?.skills.filter((skill) => skill.scope !== "repo") ?? + []; const composerPromptInjectionState = useMemo( () => getComposerPromptInjectionState(prompt), @@ -990,22 +1000,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(selectedProviderSkills, 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]); + }, [ + composerTrigger, + selectedProvider, + selectedProviderSkills, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -2396,7 +2410,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..4e0a99b36c8 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -7,6 +7,7 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProviderInstanceId, ThreadId, VcsListRefsResult, VcsRef, @@ -19,6 +20,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { projectEnvironment } from "./projects"; +import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; @@ -214,6 +216,21 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +export function useProviderSkills(target: { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; +}) { + return useEnvironmentQuery( + target.environmentId !== null && target.instanceId !== null && target.cwd !== null + ? serverEnvironment.providerSkills({ + environmentId: target.environmentId, + input: { instanceId: target.instanceId, cwd: target.cwd }, + }) + : null, + ); +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7306a0a1071..b14ee8ce74b 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -298,6 +298,11 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + providerSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:provider-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_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 48c5d9a774d..98bca4134af 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -118,6 +118,8 @@ import { ServerConfig, ServerProviderUpdateError, ServerProviderUpdateInput, + ServerProviderListSkillsInput, + ServerProviderListSkillsResult, ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, @@ -203,6 +205,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListProviderSkills: "server.listProviderSkills", serverUpdateProvider: "server.updateProvider", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", @@ -266,6 +269,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderListSkillsInput, + success: ServerProviderListSkillsResult, + error: EnvironmentAuthorizationError, +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -684,6 +693,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListProviderSkillsRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..993f41b9e63 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -91,6 +91,17 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderListSkillsInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderListSkillsInput = typeof ServerProviderListSkillsInput.Type; + +export const ServerProviderListSkillsResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderListSkillsResult = typeof ServerProviderListSkillsResult.Type; + /** * Availability of a configured provider instance from the runtime's POV. *