From 3fe112cdde7b45d67d5b802190201463b7bab87d Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:09:01 +0530 Subject: [PATCH 1/5] fix(server): discover project skills for Codex and OpenCode (#8778) (cherry picked from commit 80a14b6588f0e856983e6d10002cde9e00e9d3e2) --- .../Layers/ProviderCommandReactor.ts | 29 ++- .../src/provider/Drivers/CodexDriver.ts | 36 +++- .../src/provider/Drivers/OpenCodeDriver.ts | 50 +++++ .../src/provider/Layers/CodexProvider.ts | 47 +++++ .../provider/Layers/OpenCodeAdapter.test.ts | 2 + .../provider/Layers/OpenCodeProvider.test.ts | 2 + .../src/provider/Layers/OpenCodeProvider.ts | 8 +- .../provider/Layers/ProviderRegistry.test.ts | 194 ++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 119 ++++++++++- .../src/provider/OpenCodeServerOwner.test.ts | 8 + .../src/provider/Services/ProviderRegistry.ts | 5 + apps/server/src/provider/opencodeRuntime.ts | 35 +++- .../providerMaintenanceRunner.test.ts | 1 + .../testUtils/providerRegistryMock.ts | 1 + .../OpenCodeTextGeneration.test.ts | 2 + apps/server/src/ws.ts | 11 +- apps/web/src/components/ChatView.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 81 ++++++-- .../client-runtime/src/providerSkills.test.ts | 41 ++++ packages/client-runtime/src/providerSkills.ts | 28 ++- packages/client-runtime/src/state/server.ts | 3 +- packages/contracts/src/rpc.ts | 2 + packages/contracts/src/server.ts | 8 + 23 files changed, 682 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b91fbd43b..91155bf4f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -340,6 +340,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { } const make = Effect.gen(function* () { + const reactorScope = yield* Effect.scope; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -857,21 +858,28 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + const refreshWorkspaceSnapshot = effectiveCwd + ? providerRegistry + .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) + .pipe(Effect.forkIn(reactorScope)) + : Effect.void; const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; }) => - providerService.startSession(threadId, { - threadId, - ...(preferredProvider ? { provider: preferredProvider } : {}), - providerInstanceId: desiredInstanceId, - ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), - modelSelection: desiredModelSelection, - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: desiredRuntimeMode, - }); + providerService + .startSession(threadId, { + threadId, + ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), + modelSelection: desiredModelSelection, + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, + }) + .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -1029,6 +1037,7 @@ const make = Effect.gen(function* () { !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { + yield* refreshWorkspaceSnapshot; if (options?.pendingTurnStart === false) return activeSession; return (yield* bindSessionToThread(activeSession!)) ? activeSession : undefined; } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 6c3770a23..d599e5094 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -41,7 +41,12 @@ 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, + probeCodexSkillsForCwd, +} from "../Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; @@ -243,6 +248,34 @@ export const CodexDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (cwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.timeout("20 seconds"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Codex skills for '${cwd}'`, + cause, + }), + ), + ); return { instanceId, @@ -252,6 +285,7 @@ export const CodexDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index dce582d71..f54c3893f 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, makePendingOpenCodeProvider, + openCodeSkillsToServerProviderSkills, } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -155,6 +156,34 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime, openCodeRuntime), ); + const loadSkillsForCwd = (cwd: string) => + effectiveConfig.serverUrl.trim().length > 0 + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: effectiveConfig.binaryPath, + directory: cwd, + serverUrl: effectiveConfig.serverUrl, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + }); + return yield* openCodeRuntime.loadOpenCodeSkills(client); + }), + ) + : openCodeRuntime.loadSkillsFromCli({ + binaryPath: effectiveConfig.binaryPath, + cwd, + environment: processEnv, + }); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( @@ -199,6 +228,27 @@ export const OpenCodeDriver: ProviderDriver accentColor, enabled, snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode skills for '${cwd}'`, + cause, + }), + ), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3a0708575..905c7ca1e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -509,6 +509,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + 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, + codexAppServerArgs(input.launchArgs), + { 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), + ); + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(skillsResponse, input.cwd); +}); + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); for (const model of codexSettings.customModels) { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 0ead57c2f..aff32f676 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -499,6 +499,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntimeError({ @@ -507,6 +508,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => Effect.succeed([]), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 6ec2c4d87..0c0bf0c28 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -155,6 +155,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); }, + loadOpenCodeSkills: () => Effect.succeed([]), + loadSkillsFromCli: () => Effect.succeed([]), }; beforeEach(() => { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 5183a68d0..8a1ab4c7f 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -292,9 +292,11 @@ function trimOptional(value: string | null | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined; } -function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { +export function openCodeSkillsToServerProviderSkills( + input: OpenCodeInventory["skills"] | undefined, +): ReadonlyArray { const skills: ServerProviderSkill[] = []; - for (const skill of input.skills ?? []) { + for (const skill of input ?? []) { const name = trimOptional(skill.name); const path = trimOptional(skill.location); if (!name || !path) { @@ -516,7 +518,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value.inventory); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); const connectedCount = inventoryExit.value.inventory.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0da8fc4a7..e98589496 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -44,6 +44,7 @@ import { haveProvidersChanged, mergeProviderCapacityRefresh, mergeProviderSnapshot, + upsertProviderWorkspaceSnapshot, ProviderRegistryLive, } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; @@ -752,6 +753,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); + it("stores workspace skills and commands without changing machine metadata", () => { + const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const scopedSnapshot = { + ...provider, + checkedAt: "2026-03-25T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], + } satisfies ServerProvider; + + const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot); + + assert.deepStrictEqual(result.slashCommands, provider.slashCommands); + assert.deepStrictEqual(result.skills, provider.skills); + assert.deepStrictEqual(result.workspaceSnapshots, [ + { + cwd: "/project", + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + }, + ]); + }); + it("preserves previously discovered provider models when a refresh returns none", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -1126,6 +1162,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const machineProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const scopedProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const pendingScopedProvider = { + ...scopedProvider, + status: "error", + installed: false, + slashCommands: [], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const returnPendingSnapshot = yield* Ref.make(true); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const makeInstance = ( + provider: ServerProvider, + snapshotForCwd: NonNullable, + ): ProviderInstance => ({ + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + snapshotForCwd, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const firstInstance = makeInstance(machineProvider, () => + Effect.gen(function* () { + yield* Ref.update(snapshotCalls, (count) => count + 1); + if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + return scopedProvider; + }), + ); + const rebuiltProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + status: "warning", + installed: false, + auth: { status: "unknown" }, + } satisfies ServerProvider; + const rebuiltInstance = makeInstance(rebuiltProvider, () => + Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), + ); + const registryChanges = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([firstInstance]); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instancesRef), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(registryChanges), + subscribeChanges: PubSub.subscribe(registryChanges), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-workspace-snapshot-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); + yield* Ref.set(returnPendingSnapshot, false); + const workspaceUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const firstRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const duplicateRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(firstRefresh); + yield* Fiber.join(duplicateRefresh); + const published = yield* Fiber.join(workspaceUpdate); + assert.strictEqual(published._tag, "Some"); + const providers = yield* registry.getProviders; + assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.[0]?.skills, + scopedProvider.skills, + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + + yield* Ref.set(instancesRef, [rebuiltInstance]); + yield* PubSub.publish(registryChanges, undefined); + let rebuilt = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + rebuilt = yield* registry.getProviders; + } + assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); + assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("projects pushed rate-limit state onto the instance snapshot", () => Effect.gen(function* () { const claudeDriver = ProviderDriverKind.make("claudeAgent"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index cb6112fdd..4fc3dd117 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -95,6 +95,28 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; + +export function upsertProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string, + scopedSnapshot: ServerProvider, +): ServerProvider { + const workspaceSnapshot = { + cwd, + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + } satisfies NonNullable[number]; + return { + ...provider, + workspaceSnapshots: [ + ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), + workspaceSnapshot, + ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), + }; +} + const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver !== ProviderDriverKind.make("opencode")) { return true; @@ -157,6 +179,11 @@ export const mergeProviderSnapshot = ( : { ...nextProvider, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), ...(shouldRetainMissingOpenCodeMetadata(nextProvider) ? { slashCommands: @@ -360,6 +387,9 @@ export const ProviderRegistryLive = Layer.effect( ), ); const providersRef = yield* Ref.make>(cachedProviders); + const workspaceRefreshesRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -434,9 +464,10 @@ export const ProviderRegistryLive = Layer.effect( cacheDir: config.providerStatusCacheDir, instanceId: key, }).pipe(Effect.provideService(Path.Path, path)); + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; yield* writeProviderStatusCache({ filePath, - provider, + provider: machineProvider, ...(runtimeFence?.configRevision === undefined ? {} : { configRevision: runtimeFence.configRevision }), @@ -1004,6 +1035,28 @@ export const ProviderRegistryLive = Layer.effect( } } + const rebuiltInstanceIds = new Set( + newlyAdded + .map(([instanceId]) => instanceId) + .filter((instanceId) => previousSubs.has(instanceId)), + ); + if (rebuiltInstanceIds.size > 0) { + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = previousProviders.map((provider) => { + if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + }); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + } + // Fork long-lived subscriptions to each new/rebuilt instance's // change stream before reading its current snapshot. If the // driver's own initial probe finishes during this sync, either @@ -1199,12 +1252,76 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const providers = yield* Ref.get(providersRef); + const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); + if ( + !provider || + !provider.enabled || + provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + ) { + return providers; + } + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (!instance?.snapshotForCwd) return providers; + const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + if (current?.has(input.cwd)) return [false, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Set(current).add(input.cwd)); + return [true, next] as const; + }); + if (!claimed) return yield* Ref.get(providersRef); + return yield* instance.snapshotForCwd(input.cwd).pipe( + Effect.flatMap((scopedSnapshot) => + scopedSnapshot.status === "error" + ? Ref.get(providersRef) + : instanceRegistry.getInstance(input.instanceId).pipe( + Effect.flatMap((currentInstance) => { + if (currentInstance !== instance) return Ref.get(providersRef); + return Ref.modify(providersRef, (currentProviders) => { + const nextProviders = currentProviders.map((candidate) => + candidate.instanceId === input.instanceId && + !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) + : candidate, + ); + return [[currentProviders, nextProviders] as const, nextProviders]; + }).pipe( + Effect.tap(([previousProviders, nextProviders]) => + haveProvidersChanged(previousProviders, nextProviders) + ? PubSub.publish(changesPubSub, nextProviders) + : Effect.void, + ), + Effect.map(([, nextProviders]) => nextProviders), + ); + }), + ), + ), + Effect.ensuring( + Ref.update(workspaceRefreshesRef, (refreshes) => { + const next = new Map(refreshes); + const current = new Set(next.get(instance)); + current.delete(input.cwd); + if (current.size) next.set(instance, current); + else next.delete(instance); + return next; + }), + ), + ); + }); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshWorkspaceSnapshot: (input) => + refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, getProviderRuntimeFence: (instanceId: ProviderInstanceId) => instanceRegistry diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/apps/server/src/provider/OpenCodeServerOwner.test.ts index 053c0b1bf..5e66f0a56 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.test.ts +++ b/apps/server/src/provider/OpenCodeServerOwner.test.ts @@ -56,7 +56,9 @@ const makeRuntime = Effect.gen(function* () { runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; return { runtime, starts, closes, failNextStart, started, closed }; }); @@ -137,7 +139,9 @@ it.effect("invalidates an exited process so the next borrower starts a new one", runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; yield* Effect.scoped( @@ -183,7 +187,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; yield* Effect.scoped( @@ -236,7 +242,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () => runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, + loadSkillsFromCli: unusedRuntimeMethod, }; yield* Effect.scoped( diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index f4de4fa0f..c8afb19bb 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -51,6 +51,11 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + readonly refreshWorkspaceSnapshot: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => 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/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 05553fd89..1aa91f5d1 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -255,11 +255,19 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadOpenCodeSkills: ( + client: OpencodeClient, + ) => Effect.Effect, OpenCodeRuntimeError>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect; + readonly loadSkillsFromCli: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect, OpenCodeRuntimeError>; } function parseServerUrlFromOutput(output: string): string | null { @@ -894,7 +902,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.orElseSucceed((): ReadonlyArray => []), ); - const loadSkills = (client: OpencodeClient) => + const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( Effect.map((result) => (result.data ?? []).map((skill) => ({ @@ -903,8 +911,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { location: skill.location, })), ), - Effect.orElseSucceed((): ReadonlyArray => []), ); + const loadSkills = (client: OpencodeClient) => + loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { @@ -1010,13 +1019,35 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }; }); + const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + cwd: input.cwd, + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed(parseSkillsCliOutput(result.stdout)) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: `OpenCode skills command exited with code ${result.code}.`, + }), + ), + ), + ); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadOpenCodeSkills, loadInventoryFromCli, + loadSkillsFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index b9ed1c79d..cb2f8d18e 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -193,6 +193,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshWorkspaceSnapshot: () => Ref.get(providersRef), 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 55d88a418..0f752604f 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), + refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 39c103172..688bf4bca 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -136,6 +136,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ @@ -144,6 +145,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => Effect.succeed([]), }; const DEFAULT_TEST_MODEL_SELECTION = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3809700d0..66e512d16 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1758,9 +1758,14 @@ const makeWsRpcLayer = ( [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() + (input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + }) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cd24d2bca..c5373e2e0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -325,6 +325,7 @@ import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -8492,7 +8493,11 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={ + activeProviderStatus + ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) + : EMPTY_PROVIDER_SKILLS + } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerOverlayHeight} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5270d40f7..49b0c2104 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -444,9 +444,12 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + resolveProviderSkillsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; import type { ThreadHandoffOffer } from "./ThreadHandoff.logic"; import { ThreadHandoffTab } from "./ThreadHandoffTab"; @@ -1860,6 +1863,55 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedInstanceId, ], ); + const selectedProviderSkills = selectedProviderStatus + ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) + : []; + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const workspaceRefreshKeyRef = useRef(null); + const hadWorkspaceSnapshotRef = useRef(false); + useEffect(() => { + const hasWorkspaceSnapshot = Boolean( + gitCwd && + selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), + ); + if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { + workspaceRefreshKeyRef.current = null; + } + hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; + }, [gitCwd, selectedProviderStatus]); + useEffect(() => { + if (!gitCwd || !selectedProviderEntry) return; + const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; + const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === gitCwd, + ); + if (workspaceRefreshKeyRef.current === key) return; + if (hasWorkspaceSnapshot) { + workspaceRefreshKeyRef.current = key; + return; + } + workspaceRefreshKeyRef.current = key; + void refreshProviders({ + environmentId, + input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, + }).then( + (result) => { + const hasWorkspaceSnapshot = + result._tag === "Success" && + result.value.providers + .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) + ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); + if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { + workspaceRefreshKeyRef.current = null; + } + }, + () => { + if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null; + }, + ); + }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -2119,7 +2171,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : []), ] satisfies ReadonlyArray>; const slashMenuSkills = getProviderSkillsForSlashMenu( - selectedProviderStatus?.skills ?? [], + selectedProviderSkills, settings.showSkillsInSlashMenu, ); const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( @@ -2152,19 +2204,17 @@ 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 []; }, [ @@ -2172,6 +2222,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTrigger, providerSlashCommands, selectedProvider, + selectedProviderSkills, selectedProviderStatus, settings.showSkillsInSlashMenu, workspaceEntries.entries, @@ -5009,7 +5060,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/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 2793e1d38..0263c5611 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,3 +1,4 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -5,9 +6,33 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; +const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + workspaceSnapshots: [ + { + cwd: "/workspace/project-a", + checkedAt: "2026-01-01T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }], + }, + ], +} satisfies ServerProvider; + describe("formatProviderSkillDisplayName", () => { it("prefers the provider display name", () => { expect( @@ -226,3 +251,19 @@ describe("resolveProviderSkillSourceKind", () => { ).toBe("other"); }); }); + +describe("workspace provider snapshots", () => { + it("uses the cwd snapshot after a provider session has populated it", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }, + ]); + expect(resolveProviderSlashCommandsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project" }, + ]); + }); + + it("keeps the machine snapshot before this cwd has a provider snapshot", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); + expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index f90ffe142..b80cd5803 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,4 +1,8 @@ -import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderSkill, + ServerProviderSlashCommand, +} from "@t3tools/contracts"; export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; @@ -98,3 +102,25 @@ export function resolveProviderSkillSourceKind( return "other"; } } + +function resolveProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string | null | undefined, +) { + if (!cwd) return undefined; + return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); +} + +export function resolveProviderSkillsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["skills"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; +} + +export function resolveProviderSlashCommandsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["slashCommands"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 84158f4df..f092b7164 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -926,7 +926,8 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverRefreshProviders, concurrency: { mode: "singleFlight", - key: ({ environmentId }) => environmentId, + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.instanceId ?? null, input.cwd ?? null]), }, }), updateProvider: createEnvironmentRpcCommand(runtime, { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5b82e4883..e63c336b9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { @@ -465,6 +466,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv * refreshes. */ instanceId: Schema.optional(ProviderInstanceId), + cwd: Schema.optional(TrimmedNonEmptyString), }), success: ServerProviderUpdatedPayload, error: EnvironmentAuthorizationError, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 7aefa2b25..a3afe59f3 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -168,6 +168,13 @@ export const ServerProviderBackend = Schema.Struct({ usageLimits: Schema.optional(ServerProviderUsageLimits), }); export type ServerProviderBackend = typeof ServerProviderBackend.Type; +export const ServerProviderWorkspaceSnapshot = Schema.Struct({ + cwd: TrimmedNonEmptyString, + checkedAt: IsoDateTime, + slashCommands: Schema.Array(ServerProviderSlashCommand), + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; /** * Availability of a configured provider instance from the runtime's POV. @@ -421,6 +428,7 @@ export const ServerProvider = Schema.Struct({ usageLimits: Schema.optional(ServerProviderUsageLimits), backends: Schema.optional(Schema.Array(ServerProviderBackend)), rateLimit: Schema.optional(ServerProviderRateLimit), + workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), distribution: Schema.optionalKey(ServerProviderDistribution), updateState: Schema.optionalKey(ServerProviderUpdateState), From faac7be022b18b5ea71f3adc8decf09d7c9d2d5e Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 20:07:31 -0400 Subject: [PATCH 2/5] fix(providers): discover workspace skills everywhere (#9180) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 15fea6c5f40331c8325f33bd08b2dcbf924e935a) --- .../features/threads/NewTaskDraftScreen.tsx | 2 +- .../src/features/threads/ThreadComposer.tsx | 2 +- .../features/threads/ThreadDetailScreen.tsx | 17 +- .../threads/use-composer-command-menu.test.ts | 6 + .../threads/use-composer-command-menu.ts | 76 ++++- .../src/provider/Drivers/CursorDriver.ts | 20 ++ .../src/provider/Drivers/CursorSkills.ts | 283 ++++++++++++++++++ .../server/src/provider/Drivers/GrokDriver.ts | 20 ++ .../src/provider/Drivers/GrokSkills.test.ts | 12 +- .../server/src/provider/Drivers/GrokSkills.ts | 84 ++++-- .../src/provider/Layers/CursorAdapter.test.ts | 46 +++ .../src/provider/Layers/CursorAdapter.ts | 31 +- .../provider/Layers/CursorProvider.test.ts | 98 ++++++ .../src/provider/Layers/GrokProvider.ts | 5 +- apps/web/src/components/chat/ChatComposer.tsx | 42 ++- 15 files changed, 689 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/provider/Drivers/CursorSkills.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ba610bac4..e2fd026d8 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1088,7 +1088,7 @@ export function NewTaskDraftScreen(props: { multiline scrollEnabled value={flow.prompt} - skills={flow.selectedProviderStatus?.skills ?? []} + skills={composerMenu.skills} selection={composerMenu.selection} onChangeText={flow.setPrompt} onSelectionChange={composerMenu.onSelectionChange} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index ce908d2b1..711257559 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1744,7 +1744,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // keystroke makes resolveTranscriptCommit see a changed draft and // discard the whole transcript as stale. readOnly={voiceInput.freezesEditor} - skills={selectedProviderStatus?.skills ?? []} + skills={composerMenu.skills} selection={composerMenu.selection} onChangeText={props.onChangeDraftMessage} onSelectionChange={composerMenu.onSelectionChange} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a58347f0e..36a3a1058 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,6 +14,7 @@ import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/thre import { isRollbackActive, type RollbackTarget } from "@t3tools/client-runtime/rollback"; import { getMobileRollbackStatusPresentation } from "./rollback-status-presentation"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; import { HeaderHeightContext } from "@react-navigation/elements"; import type { @@ -643,12 +644,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo( - () => - props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) - ?.skills ?? [], - [props.serverConfig, selectedInstanceId], - ); + const selectedProviderSkills = useMemo(() => { + const provider = props.serverConfig?.providers.find( + (candidate) => candidate.instanceId === selectedInstanceId, + ); + return provider + ? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot) + : []; + }, [props.projectWorkspaceRoot, props.serverConfig, props.threadCwd, selectedInstanceId]); const aboveEditorWidgets = useMemo( () => props.sessionInteractionPresentation.widgets.filter( @@ -1044,7 +1047,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread props.rollbackStatus?.state === "manual-recovery" } environmentId={props.environmentId} - projectCwd={props.projectWorkspaceRoot} + projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} bottomInset={hasBelowEditorWidgets ? 0 : composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} onPickDraftMedia={props.onPickDraftMedia} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 1f286eeee..5911d2eeb 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -9,6 +9,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; vi.mock("../../state/queries", () => ({ useComposerPathSearch: () => ({ entries: [], isPending: false }), })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, +})); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: () => vi.fn(), +})); import type { ComposerCommandMenuProvider } from "./use-composer-command-menu"; diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index f4302d321..8eada265a 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,4 +1,7 @@ +import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "../../state/server"; import { + resolveProviderSkillsForCwd, dedupeProviderSkillsByName, getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, @@ -34,6 +37,8 @@ import { useComposerPathSearch } from "../../state/queries"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; +const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; + /** * The slice of a provider snapshot the menu reads. Narrower than `ServerProvider` * so the pure builder can be exercised without a full status fixture. @@ -336,6 +341,71 @@ export function useComposerCommandMenu({ setSelection(composerSelectionAtEnd(draftMessage)); }, [draftMessage, ownerKey]); + const skills = useMemo( + () => + selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [], + [projectCwd, selectedProviderStatus], + ); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const selectedProviderInstanceId = selectedProviderStatus?.instanceId; + const hasWorkspaceSnapshot = Boolean( + projectCwd && + selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd), + ); + const workspaceRefreshKeyRef = useRef(null); + const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); + const hadWorkspaceSnapshotRef = useRef(false); + useEffect(() => { + if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { + workspaceRefreshKeyRef.current = null; + workspaceRefreshRetryRef.current = null; + } + hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; + }, [hasWorkspaceSnapshot]); + useEffect(() => { + if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; + const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; + if (workspaceRefreshKeyRef.current === key) return; + if (hasWorkspaceSnapshot) { + workspaceRefreshKeyRef.current = key; + workspaceRefreshRetryRef.current = null; + return; + } + const retry = workspaceRefreshRetryRef.current; + if (retry?.key === key && Date.now() < retry.notBefore) return; + workspaceRefreshKeyRef.current = key; + const retryLater = () => { + if (workspaceRefreshKeyRef.current !== key) return; + workspaceRefreshKeyRef.current = null; + workspaceRefreshRetryRef.current = { + key, + notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, + }; + }; + void refreshProviders({ + environmentId, + input: { instanceId: selectedProviderInstanceId, cwd: projectCwd }, + }).then((result) => { + const refreshed = + result._tag === "Success" && + result.value.providers + .find((provider) => provider.instanceId === selectedProviderInstanceId) + ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd); + if (!refreshed && workspaceRefreshKeyRef.current === key) { + retryLater(); + } + }, retryLater); + }, [ + draftMessage, + environmentId, + hasWorkspaceSnapshot, + projectCwd, + refreshProviders, + selectedProviderInstanceId, + ]); + const trigger = useMemo(() => { if (!enabled || selection.start !== selection.end) { return null; @@ -358,7 +428,9 @@ export function useComposerCommandMenu({ () => buildComposerCommandItems({ trigger, - selectedProviderStatus, + selectedProviderStatus: selectedProviderStatus + ? { ...selectedProviderStatus, skills } + : null, providerSlashCommands, showInteractionModeToggle: showInteractionModeToggle && onUpdateInteractionMode !== undefined, @@ -371,6 +443,7 @@ export function useComposerCommandMenu({ pathSearch.entries, providerSlashCommands, selectedProviderStatus, + skills, showInteractionModeToggle, trigger, ], @@ -401,6 +474,7 @@ export function useComposerCommandMenu({ onSelectionChange, trigger, items, + skills, isLoading: pathSearch.isPending, onSelect, }; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 5db547203..5466af802 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -53,6 +53,7 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; +import { probeCursorSkills } from "./CursorSkills.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -195,6 +196,25 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCursorSkills(cwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Cursor skills for '${cwd}'`, + cause, + }), + ), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts new file mode 100644 index 000000000..5113fd3d0 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -0,0 +1,283 @@ +/** + * CursorSkills — workspace-aware discovery and native invocation for Cursor. + * + * Cursor discovers Agent Skills recursively from user and project roots but + * its ACP command catalog only appears after opening a real session. Scanning + * the same roots avoids starting an agent and its MCP servers just to populate + * a composer menu. + * + * @module provider/Drivers/CursorSkills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import { parse as parseYamlDocument } from "yaml"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; +const MAX_SKILL_DEPTH = 10; +const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); +const MAX_SKILL_SCAN_ENTRIES = 10_000; +const MAX_SKILL_SCAN_BYTES = FileSystem.Size(8_000_000); + +interface CursorSkillFrontmatter { + readonly description?: string; + readonly displayName?: string; + readonly userInvocationOnly?: boolean; + readonly userInvocable?: boolean; + readonly cliVisible: boolean; +} + +interface CursorSkillScanBudget { + remainingEntries: number; + remainingBytes: bigint; + exhausted: boolean; + incomplete: boolean; +} + +class CursorSkillsProbeError extends Schema.TaggedErrorClass()( + "CursorSkillsProbeError", + { + reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), + cwd: Schema.optional(Schema.String), + }, +) { + override get message(): string { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + return `Cursor skill discovery${location} was incomplete (${this.reason}).`; + } +} + +const orUndefined = ( + effect: Effect.Effect, + budget?: CursorSkillScanBudget, +): Effect.Effect => + effect.pipe( + Effect.map((value): A | undefined => value), + Effect.catchTags({ + PlatformError: (error) => { + if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true; + return Effect.void.pipe(Effect.as(undefined)); + }, + }), + ); + +function parseFrontmatterBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined; + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "on": + return true; + case "false": + case "no": + case "off": + return false; + default: + return undefined; + } +} + +function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) return { cliVisible: true }; + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) return undefined; + + const record = parsed as Record; + const metadata = + typeof record.metadata === "object" && record.metadata !== null + ? (record.metadata as Record) + : undefined; + const rawSurfaces = metadata?.surfaces; + const surfaces = Array.isArray(rawSurfaces) + ? rawSurfaces.filter((surface): surface is string => typeof surface === "string") + : typeof rawSurfaces === "string" + ? rawSurfaces.split(",") + : []; + const description = typeof record.description === "string" ? record.description.trim() : ""; + const displayName = typeof record.name === "string" ? record.name.trim() : ""; + return { + cliVisible: + surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"), + ...(description ? { description } : {}), + ...(displayName ? { displayName } : {}), + ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true + ? { userInvocationOnly: true } + : {}), + ...(parseFrontmatterBoolean(record["user-invocable"]) === false + ? { userInvocable: false } + : {}), + }; +} + +const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: { + readonly directory: string; + readonly scope: "user" | "project"; + readonly budget: CursorSkillScanBudget; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skills: ServerProviderSkill[] = []; + if (input.budget.exhausted) return skills; + const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget); + if (!rootDirectory) return skills; + const visitedDirectories = new Set(); + + const visit = Effect.fn("visitCursorSkillDirectory")(function* ( + directory: string, + depth: number, + ): Effect.fn.Return { + if (input.budget.exhausted) return; + const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); + if (!resolvedDirectory) { + return; + } + if ( + visitedDirectories.has(resolvedDirectory) || + (resolvedDirectory !== rootDirectory && + !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`)) + ) { + return; + } + visitedDirectories.add(resolvedDirectory); + + const skillPath = path.join(resolvedDirectory, "SKILL.md"); + const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); + if (skillInfo?.type === "File") { + let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; + if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { + const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); + if (contents !== undefined) { + input.budget.remainingBytes -= skillInfo.size; + frontmatter = parseSkillFrontmatter(contents); + } + } + const name = path.basename(resolvedDirectory).trim(); + if (frontmatter?.cliVisible && name) { + skills.push({ + name, + path: skillPath, + scope: input.scope, + enabled: true, + ...(frontmatter.displayName && frontmatter.displayName !== name + ? { displayName: frontmatter.displayName } + : {}), + ...(frontmatter.description ? { description: frontmatter.description } : {}), + ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), + }); + } + } + + const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget); + if (!entries) { + return; + } + for (const entry of [...entries].sort()) { + if (input.budget.remainingEntries === 0) { + input.budget.exhausted = true; + return; + } + input.budget.remainingEntries -= 1; + const child = path.join(resolvedDirectory, entry); + const info = yield* orUndefined(fileSystem.stat(child), input.budget); + if (info?.type !== "Directory") continue; + if (depth >= MAX_SKILL_DEPTH) { + input.budget.exhausted = true; + return; + } + yield* visit(child, depth + 1); + } + }); + + yield* visit(rootDirectory, 0); + return skills; +}); + +const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const path = yield* Path.Path; + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + const rootsBelow = (base: string, scope: "user" | "project") => [ + { directory: path.join(base, ".cursor", "skills"), scope }, + { directory: path.join(base, ".agents", "skills"), scope }, + { directory: path.join(base, ".codex", "skills"), scope }, + { directory: path.join(base, ".claude", "skills"), scope }, + ]; + const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; + + const skillsByName = new Map(); + const budget: CursorSkillScanBudget = { + remainingEntries: MAX_SKILL_SCAN_ENTRIES, + remainingBytes: MAX_SKILL_SCAN_BYTES, + exhausted: false, + incomplete: false, + }; + for (const root of roots) { + if (budget.exhausted) break; + const skills = yield* discoverSkillsInRoot({ ...root, budget }); + for (const skill of skills) { + if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); + } + } + return { + skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), + failureReason: budget.exhausted + ? ("scan-budget-exhausted" as const) + : budget.incomplete + ? ("filesystem-error" as const) + : undefined, + }; +}); + +export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + return (yield* inspectCursorSkills(cwd, environment)).skills; +}); + +export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const inspection = yield* inspectCursorSkills(cwd, environment); + if (inspection.failureReason) { + return yield* new CursorSkillsProbeError({ + reason: inspection.failureReason, + ...(cwd ? { cwd } : {}), + }); + } + return inspection.skills; +}); + +/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */ +export function hasCursorSkillMention(prompt: string): boolean { + return HAS_SKILL_MENTION_PATTERN.test(prompt); +} + +export function rewriteCursorSkillMentions( + prompt: string, + skillNames: ReadonlySet, +): string { + return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => + skillNames.has(name) ? `${prefix}/${name}` : match, + ); +} diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index ebe69c199..98ef1d16c 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -28,6 +28,7 @@ import { import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -122,6 +123,24 @@ export const GrokDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (workspaceCwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverGrokSkills(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Grok skills for '${workspaceCwd}'`, + cause, + }), + ), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))); return { instanceId, @@ -131,6 +150,7 @@ export const GrokDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 3536a37a9..13415bc35 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -93,14 +93,15 @@ describe("parseGrokInspectSkills", () => { }); describe("discoverGrokSkills", () => { - it.effect("spawns the inspect probe in the configured cwd", () => { + it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; + let exitCode = 0; const spawner = ChildProcessSpawner.make((command) => { spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -130,6 +131,13 @@ describe("discoverGrokSkills", () => { expect(spawnCwds).toEqual(["/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + + exitCode = 1; + const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( + Effect.result, + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + expect(failed._tag).toBe("Failure"); }); }); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index a7c2c2ae3..b7962205d 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -9,41 +9,57 @@ * (ignore lists, disabled skills) and includes plugin skills, which live * three levels deep under `~/.grok/installed-plugins/` where a flat scan * cannot see them. This mirrors how the Codex app-server reports skills over - * `skills/list`. Discovery is best-effort: an older CLI without `inspect`, - * a timeout, or malformed output yields an empty list, never a degraded - * provider snapshot. + * `skills/list`. Probe failures stay typed so workspace snapshots do not + * cache an empty catalog; machine-level discovery recovers them to an empty + * list without degrading the provider. * * @module provider/Drivers/GrokSkills */ import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import { ChildProcess } from "effect/unstable/process"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { spawnAndCollect } from "../providerSnapshot.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; +class GrokSkillsProbeError extends Schema.TaggedErrorClass()( + "GrokSkillsProbeError", + { + stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]), + cwd: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`; + return `\`grok inspect --json\` failed during ${this.stage}${location}${exitCode}.`; + } +} + /** * Map `grok inspect --json` output onto provider skills. Entries without a * name or a filesystem path are skipped; `userInvocable: false` skills are * kept but disabled so pickers that filter on `enabled` hide them. */ -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { +function decodeGrokInspectSkills(stdout: string): ReadonlyArray | undefined { let parsed: unknown; try { parsed = JSON.parse(stdout); } catch { - return []; + return undefined; } if (typeof parsed !== "object" || parsed === null) { - return []; + return undefined; } const entries = (parsed as Record).skills; if (!Array.isArray(entries)) { - return []; + return undefined; } const skillsByName = new Map(); @@ -75,20 +91,20 @@ export function parseGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } +export function parseGrokInspectSkills(stdout: string): ReadonlyArray { + return decodeGrokInspectSkills(stdout) ?? []; +} + /** * Run `grok inspect --json` and map the reported catalog onto provider - * skills. Never fails: any spawn error, non-zero exit, or timeout resolves - * to an empty list. + * skills. Callers that need best-effort discovery can recover this effect to + * an empty list; workspace callers leave failures typed so they are not cached. */ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( grokSettings: Pick, environment: NodeJS.ProcessEnv = process.env, cwd?: string, -): Effect.fn.Return< - ReadonlyArray, - never, - ChildProcessSpawner.ChildProcessSpawner -> { +) { const command = grokSettings.binaryPath || "grok"; const inspectResult = yield* Effect.gen(function* () { const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { @@ -102,18 +118,38 @@ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( shell: spawnCommand.shell, }), ); - }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result); + }).pipe( + Effect.mapError( + (cause) => + new GrokSkillsProbeError({ + stage: "spawn", + ...(cwd ? { cwd } : {}), + cause, + }), + ), + Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), + ); - if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) { - yield* Effect.logDebug("Grok skill discovery failed; continuing without skills."); - return []; + if (Option.isNone(inspectResult)) { + return yield* new GrokSkillsProbeError({ + stage: "timeout", + ...(cwd ? { cwd } : {}), + }); } - const output = inspectResult.success.value; + const output = inspectResult.value; if (output.code !== 0) { - yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", { + return yield* new GrokSkillsProbeError({ + stage: "exit", + ...(cwd ? { cwd } : {}), exitCode: output.code, }); - return []; } - return parseGrokInspectSkills(output.stdout); + const skills = decodeGrokInspectSkills(output.stdout); + if (!skills) { + return yield* new GrokSkillsProbeError({ + stage: "decode", + ...(cwd ? { cwd } : {}), + }); + } + return skills; }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index ba648266f..808c58861 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -256,6 +256,52 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("sends selected project skills in Cursor's native slash form", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-dispatch"); + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-dispatch-")), + ); + const requestLogPath = NodePath.join(workspace, "requests.ndjson"); + const argvLogPath = NodePath.join(workspace, "argv.txt"); + const skillDirectory = NodePath.join(workspace, ".cursor", "skills", "review"); + yield* Effect.promise(() => NodeFSP.mkdir(skillDirectory, { recursive: true })); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(skillDirectory, "SKILL.md"), "# Review\n", "utf8"), + ); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + yield* adapter.sendTurn({ + threadId, + input: "please $review this", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); + assert.deepStrictEqual( + promptRequests.map( + (request) => (request.params as Record | undefined)?.prompt, + ), + [[{ type: "text", text: "please /review this" }]], + ); + }), + ); + it.effect("stamps an ACP callback that fires before the session enters the routing map", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index f40cfa802..6e5f99df7 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -78,6 +78,11 @@ import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { BUILT_IN_ADAPTER_CONVERSATION_ROLLBACK_MODES } from "../Services/ProviderAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + discoverCursorSkills, + hasCursorSkillMention, + rewriteCursorSkillMentions, +} from "../Drivers/CursorSkills.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("cursor"); @@ -136,6 +141,7 @@ interface CursorSessionContext { readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + cursorSkillNames: ReadonlySet | undefined; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ @@ -798,6 +804,7 @@ export function makeCursorAdapter( turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + cursorSkillNames: undefined, promptsInFlight: 0, stopped: false, }; @@ -1000,8 +1007,28 @@ export function makeCursorAdapter( } const promptParts: Array = []; - if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); + const rawPrompt = input.input?.trim() ?? ""; + if (rawPrompt) { + let cursorSkillNames = ctx.cursorSkillNames; + if (hasCursorSkillMention(rawPrompt) && cursorSkillNames === undefined) { + const skills = yield* discoverCursorSkills( + ctx.session.cwd, + options?.environment, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + cursorSkillNames = new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + ctx.cursorSkillNames = cursorSkillNames; + } + const prompt = cursorSkillNames + ? rewriteCursorSkillMentions(rawPrompt, cursorSkillNames) + : rawPrompt; + promptParts.push({ type: "text", text: prompt }); } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index d7069e40f..a20587f35 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -25,6 +25,12 @@ import { resolveCursorAcpBaseModelId, resolveCursorAcpConfigUpdates, } from "./CursorProvider.ts"; +import { + discoverCursorSkills, + hasCursorSkillMention, + probeCursorSkills, + rewriteCursorSkillMentions, +} from "../Drivers/CursorSkills.ts"; const runNode = ( effect: Effect.Effect< @@ -313,6 +319,98 @@ const cursorCliCommandMissingMessage = [ "See https://cursor.com/docs/cli/installation.", ].join(" "); +describe("Cursor skills", () => { + it("discovers recursive project skills with project precedence", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }); + const workspace = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }); + const writeSkill = Effect.fn("writeCursorSkill")(function* ( + root: string, + name: string, + contents: string, + ) { + const skillDirectory = path.join(root, name); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); + }); + + yield* writeSkill( + path.join(userHome, ".cursor", "skills"), + "review", + "---\ndescription: user review\n---\n", + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills", "nested"), + "review", + "---\nname: Review changes\ndescription: project review\n---\n", + ); + yield* writeSkill( + path.join(workspace, ".cursor", "skills"), + "internal", + "---\nuser-invocable: false\n---\n", + ); + yield* writeSkill( + path.join(workspace, ".cursor", "skills"), + "oversized", + "x".repeat(1_000_001), + ); + yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(userHome, ".codex", "skills"), + "not a directory", + ); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "internal", + path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"), + scope: "project", + enabled: true, + userInvocable: false, + }, + { + name: "oversized", + path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"), + scope: "project", + enabled: true, + }, + { + name: "review", + displayName: "Review changes", + description: "project review", + path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Failure"); + }), + )); + + it("rewrites only discovered skill mentions into Cursor slash invocations", () => { + expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); + expect(hasCursorSkillMention("please $review this")).toBe(true); + expect( + rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])), + ).toBe("use $review, keep $HOME and 5$review"); + expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe( + "please /review this", + ); + }); +}); + describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { expect( diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index cbb02cc0a..1030d0304 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -454,7 +454,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? { status: "unauthenticated" } : { status: "unknown" }; - const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( + Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), + Effect.orElseSucceed(() => []), + ); const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 49b0c2104..5f4552966 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -457,6 +457,8 @@ import { ProviderBindingConflictNotice } from "./ProviderBindingConflictNotice"; import { QuickQuestionDialog } from "./QuickQuestionDialog"; import { SessionResourcesDialog } from "./SessionResourcesDialog"; +const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; + const runtimeModeConfig: Record< RuntimeMode, { label: string; description: string; icon: LucideIcon } @@ -1870,6 +1872,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reportFailure: false, }); const workspaceRefreshKeyRef = useRef(null); + const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { const hasWorkspaceSnapshot = Boolean( @@ -1878,6 +1881,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; + workspaceRefreshRetryRef.current = null; } hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot; }, [gitCwd, selectedProviderStatus]); @@ -1890,28 +1894,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = key; + workspaceRefreshRetryRef.current = null; return; } + const retry = workspaceRefreshRetryRef.current; + if (retry?.key === key && Date.now() < retry.notBefore) return; workspaceRefreshKeyRef.current = key; + const retryLater = () => { + if (workspaceRefreshKeyRef.current !== key) return; + workspaceRefreshKeyRef.current = null; + workspaceRefreshRetryRef.current = { + key, + notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS, + }; + }; void refreshProviders({ environmentId, input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd }, - }).then( - (result) => { - const hasWorkspaceSnapshot = - result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); - if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { - workspaceRefreshKeyRef.current = null; - } - }, - () => { - if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null; - }, - ); - }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]); + }).then((result) => { + const hasWorkspaceSnapshot = + result._tag === "Success" && + result.value.providers + .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) + ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); + if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { + retryLater(); + } + }, retryLater); + }, [environmentId, gitCwd, prompt, refreshProviders, selectedProviderEntry]); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], From c651998a187a307c20f12182081a1b1a6ecff1de Mon Sep 17 00:00:00 2001 From: Barry <43803274+BarryHenryJr@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:58:56 -1000 Subject: [PATCH 3/5] fix(server): load OpenCode workspace skills via SDK to avoid 64KB CLI pipe truncation (#9585) (cherry picked from commit 2152d44de2db30a6bae965b0afd30be080e5c872) --- .../src/provider/Drivers/OpenCodeDriver.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index f54c3893f..72c1c0683 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -156,6 +156,14 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime, openCodeRuntime), ); + // NOTE: the local branch intentionally uses the shared SDK server + // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes + // its full JSON inventory to stdout, but the Bun-compiled binary does + // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the + // piped output arrives truncated and unparseable — which degrades to an + // empty skill list and poisons the workspace snapshot the `$` picker + // reads. The SDK `app.skills` endpoint honors the per-request directory + // and returns complete results regardless of size. const loadSkillsForCwd = (cwd: string) => effectiveConfig.serverUrl.trim().length > 0 ? Effect.scoped( @@ -179,11 +187,17 @@ export const OpenCodeDriver: ProviderDriver return yield* openCodeRuntime.loadOpenCodeSkills(client); }), ) - : openCodeRuntime.loadSkillsFromCli({ - binaryPath: effectiveConfig.binaryPath, - cwd, - environment: processEnv, - }); + : serverOwner.withServer((server) => + openCodeRuntime.loadOpenCodeSkills( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), + }), + ), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( From d27d17f42fc63e0df92bb06ed0f83a765a15c1d2 Mon Sep 17 00:00:00 2001 From: Ezra Date: Sat, 5 Sep 2026 02:27:27 +0300 Subject: [PATCH 4/5] fix(cursor): discover symlinked skills as package boundaries (#9420) (cherry picked from commit 087cfb8ae262f344f7e409f5a8c0eda6f0ef12f9) --- .../src/provider/Drivers/CursorSkills.ts | 23 +++++---- .../provider/Layers/CursorProvider.test.ts | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 5113fd3d0..7b0637267 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -147,16 +147,18 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( if (!resolvedDirectory) { return; } - if ( - visitedDirectories.has(resolvedDirectory) || - (resolvedDirectory !== rootDirectory && - !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`)) - ) { + if (visitedDirectories.has(resolvedDirectory)) { return; } visitedDirectories.add(resolvedDirectory); + // A symlink whose target lives outside the root is a skill package + // boundary: read its own SKILL.md so linked skill libraries show up, but + // never walk the target tree. + const insideRoot = + resolvedDirectory === rootDirectory || + resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); - const skillPath = path.join(resolvedDirectory, "SKILL.md"); + const skillPath = path.join(directory, "SKILL.md"); const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); if (skillInfo?.type === "File") { let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; @@ -167,7 +169,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( frontmatter = parseSkillFrontmatter(contents); } } - const name = path.basename(resolvedDirectory).trim(); + const name = path.basename(directory).trim(); if (frontmatter?.cliVisible && name) { skills.push({ name, @@ -184,7 +186,10 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( } } - const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget); + if (!insideRoot) { + return; + } + const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); if (!entries) { return; } @@ -194,7 +199,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( return; } input.budget.remainingEntries -= 1; - const child = path.join(resolvedDirectory, entry); + const child = path.join(directory, entry); const info = yield* orUndefined(fileSystem.stat(child), input.budget); if (info?.type !== "Directory") continue; if (depth >= MAX_SKILL_DEPTH) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index a20587f35..cb5c5c0ee 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -399,6 +399,56 @@ describe("Cursor skills", () => { }), )); + it("treats a symlinked skill outside the root as a package boundary", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }); + const workspace = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }); + const library = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-library-", + }); + const writeSkill = Effect.fn("writeCursorSkill")(function* ( + directory: string, + contents: string, + ) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); + }); + + // A skill package managed in a config repo and installed by symlink. + // Its own SKILL.md must be discovered under the link name, but nothing + // below the target may be walked. + yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); + yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); + const root = path.join(workspace, ".cursor", "skills"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "review", + description: "shared", + path: path.join(root, "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Success"); + }), + )); + it("rewrites only discovered skill mentions into Cursor slash invocations", () => { expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); expect(hasCursorSkillMention("please $review this")).toBe(true); From 8a44ea6d753a67386abc0bfdad946f094b93ffef Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Sun, 6 Sep 2026 09:49:28 -0600 Subject: [PATCH 5/5] fix(providers): preserve Pylon discovery and retry behavior --- .../src/provider/Drivers/CursorSkills.ts | 2 +- .../src/provider/Layers/CodexProvider.ts | 2 +- .../src/provider/Layers/CursorAdapter.test.ts | 96 +++++++++++-------- .../src/provider/Layers/CursorAdapter.ts | 22 ++--- .../provider/Layers/CursorProvider.test.ts | 20 ++-- .../provider/Layers/ProviderRegistry.test.ts | 34 ++++--- docs/user/composer.md | 5 + 7 files changed, 100 insertions(+), 81 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 7b0637267..cb103fa2c 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -273,7 +273,7 @@ export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* ( return inspection.skills; }); -/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */ +/** Cursor invokes Agent Skills with `/name`; Pylon composers insert `$name`. */ export function hasCursorSkillMention(prompt: string): boolean { return HAS_SKILL_MENTION_PATTERN.test(prompt); } diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 905c7ca1e..3b3a00979 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -516,7 +516,7 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) { - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const resolvedHomePath = input.homePath ? resolveProviderHomePath(input.homePath) : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { ...input.environment, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 808c58861..8c05bd09f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -256,50 +256,62 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect("sends selected project skills in Cursor's native slash form", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const settings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-skill-dispatch"); - const workspace = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-dispatch-")), - ); - const requestLogPath = NodePath.join(workspace, "requests.ndjson"); - const argvLogPath = NodePath.join(workspace, "argv.txt"); - const skillDirectory = NodePath.join(workspace, ".cursor", "skills", "review"); - yield* Effect.promise(() => NodeFSP.mkdir(skillDirectory, { recursive: true })); - yield* Effect.promise(() => - NodeFSP.writeFile(NodePath.join(skillDirectory, "SKILL.md"), "# Review\n", "utf8"), - ); - yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); - const wrapperPath = yield* Effect.promise(() => - makeProbeWrapper(requestLogPath, argvLogPath), - ); - yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + it.effect( + "retries incomplete project skill discovery before using Cursor's native slash form", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-dispatch"); + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-dispatch-")), + ); + const requestLogPath = NodePath.join(workspace, "requests.ndjson"); + const argvLogPath = NodePath.join(workspace, "argv.txt"); + const skillDirectory = NodePath.join(workspace, ".cursor", "skills", "review"); + yield* Effect.promise(() => NodeFSP.mkdir(skillDirectory, { recursive: true })); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(skillDirectory, "SKILL.md"), "# Review\n", "utf8"), + ); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("cursor"), - cwd: workspace, - runtimeMode: "full-access", - modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, - }); - yield* adapter.sendTurn({ - threadId, - input: "please $review this", - attachments: [], - }); - yield* adapter.stopSession(threadId); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + const blockedRoot = NodePath.join(workspace, ".agents", "skills"); + yield* Effect.promise(() => + NodeFSP.mkdir(NodePath.dirname(blockedRoot), { recursive: true }), + ); + yield* Effect.promise(() => NodeFSP.writeFile(blockedRoot, "not a directory", "utf8")); + yield* adapter.sendTurn({ threadId, input: "please $review this", attachments: [] }); + yield* Effect.promise(() => NodeFSP.rm(blockedRoot)); + yield* adapter.sendTurn({ + threadId, + input: "please $review this", + attachments: [], + }); + yield* adapter.stopSession(threadId); - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); - assert.deepStrictEqual( - promptRequests.map( - (request) => (request.params as Record | undefined)?.prompt, - ), - [[{ type: "text", text: "please /review this" }]], - ); - }), + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); + assert.deepStrictEqual( + promptRequests.map( + (request) => (request.params as Record | undefined)?.prompt, + ), + [ + [{ type: "text", text: "please $review this" }], + [{ type: "text", text: "please /review this" }], + ], + ); + }), ); it.effect("stamps an ACP callback that fires before the session enters the routing map", () => diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 6e5f99df7..061cb8adb 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -79,7 +79,7 @@ import { BUILT_IN_ADAPTER_CONVERSATION_ROLLBACK_MODES } from "../Services/Provid import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { - discoverCursorSkills, + probeCursorSkills, hasCursorSkillMention, rewriteCursorSkillMentions, } from "../Drivers/CursorSkills.ts"; @@ -1011,19 +1011,19 @@ export function makeCursorAdapter( if (rawPrompt) { let cursorSkillNames = ctx.cursorSkillNames; if (hasCursorSkillMention(rawPrompt) && cursorSkillNames === undefined) { - const skills = yield* discoverCursorSkills( - ctx.session.cwd, - options?.environment, - ).pipe( + const skills = yield* probeCursorSkills(ctx.session.cwd, options?.environment).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), + Effect.option, ); - cursorSkillNames = new Set( - skills - .filter((skill) => skill.enabled && skill.userInvocable !== false) - .map((skill) => skill.name), - ); - ctx.cursorSkillNames = cursorSkillNames; + if (Option.isSome(skills)) { + cursorSkillNames = new Set( + skills.value + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + ctx.cursorSkillNames = cursorSkillNames; + } } const prompt = cursorSkillNames ? rewriteCursorSkillMentions(rawPrompt, cursorSkillNames) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index cb5c5c0ee..87e5648f4 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -329,10 +329,12 @@ describe("Cursor skills", () => { directory: NodeOS.tmpdir(), prefix: "cursor-skills-home-", }); - const workspace = yield* fileSystem.makeTempDirectory({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-workspace-", - }); + const workspace = yield* fileSystem + .makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }) + .pipe(Effect.flatMap(fileSystem.realPath)); const writeSkill = Effect.fn("writeCursorSkill")(function* ( root: string, name: string, @@ -408,10 +410,12 @@ describe("Cursor skills", () => { directory: NodeOS.tmpdir(), prefix: "cursor-skills-home-", }); - const workspace = yield* fileSystem.makeTempDirectory({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-workspace-", - }); + const workspace = yield* fileSystem + .makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }) + .pipe(Effect.flatMap(fileSystem.realPath)); const library = yield* fileSystem.makeTempDirectory({ directory: NodeOS.tmpdir(), prefix: "cursor-skills-library-", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e98589496..0d89d3b12 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1208,10 +1208,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te displayName: undefined, enabled: true, snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - packageName: null, - }), + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), getSnapshot: Effect.succeed(provider), refresh: Effect.succeed(provider), streamChanges: Stream.empty, @@ -1277,17 +1280,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Ref.set(returnPendingSnapshot, false); const workspaceUpdate = yield* registry.streamChanges.pipe( Stream.runHead, - Effect.forkChild, + Effect.forkChild({ startImmediately: true }), ); - yield* Effect.yieldNow; const firstRefresh = yield* registry .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); + .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(probeStarted); const duplicateRefresh = yield* registry .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; + .pipe(Effect.forkChild({ startImmediately: true })); assert.strictEqual(yield* Ref.get(snapshotCalls), 2); yield* Deferred.succeed(releaseProbe, undefined); yield* Fiber.join(firstRefresh); @@ -1303,17 +1304,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + const rebuildUpdate = yield* registry.streamChanges.pipe( + Stream.filter((providers) => providers[0]?.checkedAt === rebuiltProvider.checkedAt), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); yield* Ref.set(instancesRef, [rebuiltInstance]); yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } + const rebuilt = Option.getOrThrow(yield* Fiber.join(rebuildUpdate)); assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); }).pipe(Effect.provide(runtimeServices)); diff --git a/docs/user/composer.md b/docs/user/composer.md index 57da54e40..24486a2b7 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -196,6 +196,11 @@ such as System, Personal, Project, or App. On mobile, these menus are also available on the **New task** screen before you start a thread. They use the skills and commands from the selected environment and provider. +For Codex, Claude, Cursor, Grok, and OpenCode, the skill list includes skills from the current +project or worktree. Web, desktop, and mobile request this list from the connected environment, +so remote projects use their remote skills. Discovery can take a moment after you switch +providers or workspaces. If discovery fails, later typing retries after a short cooldown. + By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in slash menu** in **Settings → General** in the web or desktop app. Skill results use the `/skill:Skill Name` label and add the same `$name` skill token to your message. The original skill