From 932bb4cc961eaf83432fcd13a35540f50630ee8a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:08:03 -0400 Subject: [PATCH 01/12] Expose project Codex skills in chat --- .../src/provider/Drivers/CodexDriver.ts | 30 +++- .../src/provider/Layers/CodexProvider.ts | 165 +++++++++--------- .../provider/Layers/ProviderRegistry.test.ts | 14 ++ .../src/provider/Layers/ProviderRegistry.ts | 14 ++ apps/server/src/provider/ProviderDriver.ts | 4 + .../src/provider/Services/ProviderRegistry.ts | 7 + .../providerMaintenanceRunner.test.ts | 1 + .../testUtils/providerRegistryMock.ts | 1 + apps/server/src/ws.ts | 28 +++ apps/web/src/components/chat/ChatComposer.tsx | 45 +++-- apps/web/src/state/queries.ts | 20 +++ .../src/state/projectCommands.ts | 6 + packages/contracts/src/rpc.ts | 13 ++ packages/contracts/src/server.ts | 20 +++ 14 files changed, 272 insertions(+), 96 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..831f97926ca 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, + makePendingCodexProvider, + probeCodexAppServerProvider, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -161,6 +165,29 @@ export const CodexDriver: ProviderDriver = { ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); + const listSkills = Effect.fn("CodexDriver.listSkills")(function* (cwd: string) { + return yield* probeCodexAppServerProvider({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + cwd, + customModels: [], + environment: processEnv, + includeModels: false, + }).pipe( + Effect.scoped, + Effect.map((result) => result.skills), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to list Codex skills: ${cause.message}`, + cause, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + }); // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place @@ -209,6 +236,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..1d84968b5dc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -286,94 +286,99 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly cwd: string; - readonly customModels?: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; -}) { - // `~` 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". - // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const environment = { - ...input.environment, - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.cwd, - env: environment, - extendEnv: true, - forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ) - .pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerSpawnError({ - command: `${input.binaryPath} app-server`, - cause, - }), - ), +export const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")( + function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + readonly includeModels?: boolean; + }) { + // `~` 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". + // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), ); - const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(clientContext), - ); - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); - yield* client.notify("initialized", undefined); + const initialize = yield* client.request("initialize", { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + }); + 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; + + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return { + account: accountResponse, + version, + models: appendCustomCodexModels([], input.customModels ?? []), + skills: [], + } satisfies CodexAppServerProviderSnapshot; + } - // 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; + let skillsResponse: CodexSchema.V2SkillsListResponse; + let models: ReadonlyArray; + if (input.includeModels === false) { + skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + models = []; + } else { + [skillsResponse, models] = yield* Effect.all( + [client.request("skills/list", { cwds: [input.cwd] }), requestAllCodexModels(client)], + { concurrency: "unbounded" }, + ); + } - const accountResponse = yield* client.request("account/read", {}); - if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, version, - models: appendCustomCodexModels([], input.customModels ?? []), - skills: [], + models: appendCustomCodexModels(models, input.customModels ?? []), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; - } - - const [skillsResponse, models] = yield* Effect.all( - [ - client.request("skills/list", { - cwds: [input.cwd], - }), - requestAllCodexModels(client), - ], - { concurrency: "unbounded" }, - ); - - return { - account: accountResponse, - version, - models: appendCustomCodexModels(models, input.customModels ?? []), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), - } satisfies CodexAppServerProviderSnapshot; -}); + }, +); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..784b82da283 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -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 listedSkillCwds = yield* Ref.make>([]); + const projectSkill = { + name: "project-review", + path: "/tmp/project/.codex/skills/project-review/SKILL.md", + enabled: true, + scope: "repo", + } 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(listedSkillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([projectSkill])), } 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" }), + [projectSkill], + ); + assert.deepStrictEqual(yield* Ref.get(listedSkillCwds), ["/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..f2f4f9cf376 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -486,6 +486,19 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const listSkills = Effect.fn("ProviderRegistry.listSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!instance?.listSkills) { + return undefined; + } + return yield* instance.listSkills(input.cwd); + }); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -688,6 +701,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..0adf91785f7 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,9 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + readonly listSkills?: ( + cwd: string, + ) => Effect.Effect, ProviderDriverError>; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..54ce1530563 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,16 +10,23 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderSkill, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { ProviderDriverError } from "../Errors.ts"; export type ProviderMaintenanceActionKind = "update"; export interface ProviderRegistryShape { + readonly listSkills: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => Effect.Effect | undefined, ProviderDriverError>; + /** * Read the latest provider snapshots for every configured instance. * Multiple snapshots may share the same `provider` kind (multiple diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..03265410633 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + listSkills: () => Effect.sync(() => undefined), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..3c15444f6aa 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + listSkills: () => Effect.sync(() => undefined), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..da47e6d5c1b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,6 +43,7 @@ import { ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, + ServerProviderSkillsError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, @@ -286,6 +287,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], @@ -1251,6 +1253,32 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect( + WS_METHODS.serverListProviderSkills, + Effect.gen(function* () { + const skills = yield* providerRegistry.listSkills(input); + if (!skills) { + return yield* new ServerProviderSkillsError({ + instanceId: input.instanceId, + cwd: input.cwd, + message: "This provider does not support project skill discovery.", + }); + } + return { skills }; + }).pipe( + Effect.mapError((cause) => + cause._tag === "ServerProviderSkillsError" + ? cause + : new ServerProviderSkillsError({ + instanceId: input.instanceId, + cwd: input.cwd, + message: cause.message, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [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..91141c6a0e6 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"; @@ -935,6 +936,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerSkills = useProviderSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: composerTriggerKind === "skill", + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -990,22 +997,29 @@ 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( + providerSkills.data?.skills ?? 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 []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + providerSkills.data, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1070,7 +1084,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerSkills.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..31e6d52a854 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, @@ -214,6 +215,25 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +export function useProviderSkills(target: { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; +}) { + return useEnvironmentQuery( + target.enabled && + target.environmentId !== null && + target.instanceId !== null && + target.cwd !== null + ? projectEnvironment.listProviderSkills({ + 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/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..b4cf2b6827b 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -55,6 +55,12 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.projectId]), }; return { + listProviderSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:providers:list-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:search-entries", tag: WS_METHODS.projectsSearchEntries, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..df9a6f9079e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -122,6 +122,9 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ServerProviderSkillsError, + ServerProviderSkillsInput, + ServerProviderSkillsResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, @@ -154,6 +157,9 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + // Provider metadata methods + serverListProviderSkills: "server.listProviderSkills", + // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -266,6 +272,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderSkillsInput, + success: ServerProviderSkillsResult, + error: Schema.Union([ServerProviderSkillsError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -683,6 +695,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, + WsServerListProviderSkillsRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..2a429f7cdb4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -91,6 +91,26 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderSkillsInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderSkillsInput = typeof ServerProviderSkillsInput.Type; + +export const ServerProviderSkillsResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderSkillsResult = typeof ServerProviderSkillsResult.Type; + +export class ServerProviderSkillsError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsError", + { + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, + }, +) {} + /** * Availability of a configured provider instance from the runtime's POV. * From 3d0cc5ef39c7fa18ea9a960b1237970d006a9c48 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:38:24 -0400 Subject: [PATCH 02/12] Skip skill probes for disabled Codex instances --- apps/server/src/provider/Drivers/CodexDriver.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 831f97926ca..f0f9a1861c5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -166,6 +166,10 @@ export const CodexDriver: ProviderDriver = { }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); const listSkills = Effect.fn("CodexDriver.listSkills")(function* (cwd: string) { + if (!effectiveConfig.enabled) { + return []; + } + return yield* probeCodexAppServerProvider({ binaryPath: effectiveConfig.binaryPath, homePath: effectiveConfig.homePath, From 8902954d9e5a8bd8023b2b9c737e3bf0776d17d0 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:38:35 -0400 Subject: [PATCH 03/12] Bound Codex project skill probes --- apps/server/src/provider/Drivers/CodexDriver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index f0f9a1861c5..9772a1b289f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -44,7 +44,7 @@ import { import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { AUTH_PROBE_TIMEOUT_MS, type ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -189,6 +189,7 @@ export const CodexDriver: ProviderDriver = { cause, }), ), + Effect.timeout(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); }); From abc7bcca050d23a27e4d484140ff515c9caa3f1e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:38:59 -0400 Subject: [PATCH 04/12] Keep fallback skills usable while loading --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 91141c6a0e6..92176db1bbb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1085,7 +1085,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerMenuLoading = (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || - (composerTriggerKind === "skill" && providerSkills.isPending); + (composerTriggerKind === "skill" && providerSkills.isPending && composerMenuItems.length === 0); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; From ef9e9726361e1fc5afca781df8df1e23f4305e36 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:39:03 -0400 Subject: [PATCH 05/12] Preserve project skill metadata in the editor --- apps/web/src/components/chat/ChatComposer.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 92176db1bbb..a9684ff98d3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,6 +9,7 @@ import type { RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderSkill, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -128,6 +129,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; const runtimeModeConfig: Record< RuntimeMode, @@ -942,6 +944,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: gitCwd, enabled: composerTriggerKind === "skill", }); + const providerSkillsTargetKey = `${environmentId ?? ""}\0${selectedProviderStatus?.instanceId ?? ""}\0${gitCwd ?? ""}`; + const [cachedProviderSkills, setCachedProviderSkills] = useState<{ + readonly targetKey: string; + readonly skills: ReadonlyArray; + } | null>(null); + useEffect(() => { + if (providerSkills.data) { + setCachedProviderSkills({ + targetKey: providerSkillsTargetKey, + skills: providerSkills.data.skills, + }); + } + }, [providerSkills.data, providerSkillsTargetKey]); + const composerProviderSkills = + providerSkills.data?.skills ?? + (cachedProviderSkills?.targetKey === providerSkillsTargetKey + ? cachedProviderSkills.skills + : selectedProviderStatus?.skills) ?? + EMPTY_PROVIDER_SKILLS; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -997,10 +1018,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills( - providerSkills.data?.skills ?? selectedProviderStatus?.skills ?? [], - composerTrigger.query, - ).map((skill) => ({ + return searchProviderSkills(composerProviderSkills, composerTrigger.query).map((skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, provider: selectedProvider, @@ -1015,7 +1033,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return []; }, [ composerTrigger, - providerSkills.data, + composerProviderSkills, selectedProvider, selectedProviderStatus, workspaceEntries.entries, @@ -2411,7 +2429,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={composerProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} From 093ff08d08f00a34e9865ffedac1272c19cd04a2 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:39:13 -0400 Subject: [PATCH 06/12] Surface typed Codex skill probe timeouts --- apps/server/src/provider/Drivers/CodexDriver.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 9772a1b289f..2289086d7af 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -189,7 +189,15 @@ export const CodexDriver: ProviderDriver = { cause, }), ), - Effect.timeout(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), + Effect.timeoutOrElse({ + duration: Duration.millis(AUTH_PROBE_TIMEOUT_MS), + orElse: () => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Timed out listing Codex skills after ${AUTH_PROBE_TIMEOUT_MS}ms`, + }), + }), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); }); From 8b3b49d146d559ad64f27ba728c32f419a590e3c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:39:17 -0400 Subject: [PATCH 07/12] Preserve provider skill error causes --- apps/server/src/ws.ts | 3 ++- packages/contracts/src/server.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index da47e6d5c1b..836e85e2250 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1273,7 +1273,8 @@ const makeWsRpcLayer = ( : new ServerProviderSkillsError({ instanceId: input.instanceId, cwd: input.cwd, - message: cause.message, + message: "Failed to list provider skills.", + cause, }), ), ), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 2a429f7cdb4..63e5039be4b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -108,6 +108,7 @@ export class ServerProviderSkillsError extends Schema.TaggedErrorClass Date: Sat, 11 Jul 2026 20:01:18 -0400 Subject: [PATCH 08/12] Avoid rewriting focused composer when skills load Co-authored-by: Cursor --- .../src/components/ComposerPromptEditor.tsx | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 18579cda6d3..7729cd85bf4 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -344,6 +344,12 @@ class ComposerSkillNode extends DecoratorNode { /> ); } + + setSkillPresentation(skillLabel: string, skillDescription: string | null): void { + const writable = this.getWritable(); + writable.__skillLabel = skillLabel; + writable.__skillDescription = skillDescription; + } } function $createComposerSkillNode( @@ -354,6 +360,34 @@ function $createComposerSkillNode( return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } +function $updateComposerSkillMetadata( + skillMetadata: ReadonlyMap, +): void { + const visit = (node: LexicalNode): void => { + if (node instanceof ComposerSkillNode) { + const metadata = skillMetadata.get(node.__skillName); + if (!metadata) { + return; + } + if ( + node.__skillLabel === metadata.label && + node.__skillDescription === metadata.description + ) { + return; + } + node.setSkillPresentation(metadata.label, metadata.description); + return; + } + if ($isElementNode(node)) { + for (const child of node.getChildren()) { + visit(child); + } + } + }; + + visit($getRoot()); +} + function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } @@ -1460,12 +1494,21 @@ function ComposerPromptEditorInner({ isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; + const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; if (shouldRewriteEditorState) { $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + } else if (skillsChanged) { + // Refresh chip presentation without clearing Lexical state so an + // in-flight `$` skill probe cannot reset the focused composer. + $updateComposerSkillMetadata(skillMetadataRef.current); } - if (shouldRewriteEditorState || isFocused) { + const shouldSyncSelection = + shouldRewriteEditorState || + (isFocused && + (previousSnapshot.value !== value || + previousSnapshot.cursor !== normalizedCursor || + contextsChanged)); + if (shouldSyncSelection) { $setSelectionAtComposerOffset(normalizedCursor); } }); From 4193148ed03348f54f6ec462c6c1e0c0b76fb74d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 20:02:49 -0400 Subject: [PATCH 09/12] Split unsupported provider skill listing errors Co-authored-by: Cursor --- apps/server/src/ws.ts | 8 ++++---- packages/contracts/src/rpc.ts | 7 ++++++- packages/contracts/src/server.ts | 21 ++++++++++++++++++--- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 836e85e2250..cf947bff5d2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -44,6 +44,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, @@ -1259,21 +1260,20 @@ const makeWsRpcLayer = ( Effect.gen(function* () { const skills = yield* providerRegistry.listSkills(input); if (!skills) { - return yield* new ServerProviderSkillsError({ + return yield* new ServerProviderSkillsUnsupportedError({ instanceId: input.instanceId, cwd: input.cwd, - message: "This provider does not support project skill discovery.", }); } return { skills }; }).pipe( Effect.mapError((cause) => - cause._tag === "ServerProviderSkillsError" + cause._tag === "ServerProviderSkillsError" || + cause._tag === "ServerProviderSkillsUnsupportedError" ? cause : new ServerProviderSkillsError({ instanceId: input.instanceId, cwd: input.cwd, - message: "Failed to list provider skills.", cause, }), ), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index df9a6f9079e..0dd3df5556e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -123,6 +123,7 @@ import { ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, ServerProviderSkillsInput, ServerProviderSkillsResult, ServerTraceDiagnosticsResult, @@ -275,7 +276,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { payload: ServerProviderSkillsInput, success: ServerProviderSkillsResult, - error: Schema.Union([ServerProviderSkillsError, EnvironmentAuthorizationError]), + error: Schema.Union([ + ServerProviderSkillsError, + ServerProviderSkillsUnsupportedError, + EnvironmentAuthorizationError, + ]), }); export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 63e5039be4b..add23a2293d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -102,15 +102,30 @@ export const ServerProviderSkillsResult = Schema.Struct({ }); export type ServerProviderSkillsResult = typeof ServerProviderSkillsResult.Type; +export class ServerProviderSkillsUnsupportedError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsUnsupportedError", + { + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Provider instance '${this.instanceId}' does not support project skill discovery`; + } +} + export class ServerProviderSkillsError extends Schema.TaggedErrorClass()( "ServerProviderSkillsError", { instanceId: ProviderInstanceId, cwd: TrimmedNonEmptyString, - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), + cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + return `Failed to list provider skills for instance '${this.instanceId}'`; + } +} /** * Availability of a configured provider instance from the runtime's POV. From 85b21bf934dad72536eb00d4c587d62032adf2ed Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 22:56:34 -0400 Subject: [PATCH 10/12] Reset stale skill chip metadata --- apps/web/src/components/ComposerPromptEditor.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 7729cd85bf4..a0bf2508099 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -366,16 +366,12 @@ function $updateComposerSkillMetadata( const visit = (node: LexicalNode): void => { if (node instanceof ComposerSkillNode) { const metadata = skillMetadata.get(node.__skillName); - if (!metadata) { + const label = metadata?.label ?? node.__skillName; + const description = metadata?.description ?? null; + if (node.__skillLabel === label && node.__skillDescription === description) { return; } - if ( - node.__skillLabel === metadata.label && - node.__skillDescription === metadata.description - ) { - return; - } - node.setSkillPresentation(metadata.label, metadata.description); + node.setSkillPresentation(label, description); return; } if ($isElementNode(node)) { From 52e338143c91c39d4de1df0524c8b9e672d25177 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 22:57:22 -0400 Subject: [PATCH 11/12] Fix provider skills error narrowing --- apps/server/src/ws.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cf947bff5d2..d1b005038ed 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1268,7 +1268,6 @@ const makeWsRpcLayer = ( return { skills }; }).pipe( Effect.mapError((cause) => - cause._tag === "ServerProviderSkillsError" || cause._tag === "ServerProviderSkillsUnsupportedError" ? cause : new ServerProviderSkillsError({ From 928f588d500b09b67b5e42e7c3b13b94ce891977 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 23:10:26 -0400 Subject: [PATCH 12/12] Preserve skill chip metadata when unavailable --- apps/web/src/components/ComposerPromptEditor.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index a0bf2508099..36af0efac59 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -366,8 +366,10 @@ function $updateComposerSkillMetadata( const visit = (node: LexicalNode): void => { if (node instanceof ComposerSkillNode) { const metadata = skillMetadata.get(node.__skillName); - const label = metadata?.label ?? node.__skillName; - const description = metadata?.description ?? null; + if (!metadata) { + return; + } + const { label, description } = metadata; if (node.__skillLabel === label && node.__skillDescription === description) { return; }