From 4a0860c0565cea402cda4da5e6a962592d7cd4af Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Sat, 19 Sep 2026 21:30:49 -0700 Subject: [PATCH 1/3] fix(server): show new skills in the picker without restarting Workspace skill catalogs were cached after the first snapshot, so a skill added while T3 Code was running stayed invisible until restart. Fingerprint each provider's skill directories and force-refresh only when that signature changes. Co-authored-by: Cursor --- .../src/provider/Drivers/AntigravityDriver.ts | 14 +- .../src/provider/Drivers/AntigravitySkills.ts | 18 +++ .../src/provider/Drivers/ClaudeDriver.ts | 6 +- .../src/provider/Drivers/ClaudeSkills.test.ts | 20 ++- .../src/provider/Drivers/ClaudeSkills.ts | 15 ++ .../src/provider/Drivers/CodexDriver.ts | 5 + .../src/provider/Drivers/CodexHomeLayout.ts | 16 ++ .../src/provider/Drivers/CursorDriver.ts | 8 +- .../src/provider/Drivers/CursorSkills.ts | 28 +++- .../server/src/provider/Drivers/GrokDriver.ts | 4 +- .../src/provider/Drivers/GrokSkills.test.ts | 24 ++- .../server/src/provider/Drivers/GrokSkills.ts | 24 +++ .../src/provider/Drivers/OpenCodeDriver.ts | 20 +++ .../provider/Layers/ProviderRegistry.test.ts | 118 ++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 123 ++++++++++++++- apps/server/src/provider/ProviderDriver.ts | 7 + .../src/provider/Services/ProviderRegistry.ts | 6 + .../provider/skillCatalogFingerprint.test.ts | 101 ++++++++++++ .../src/provider/skillCatalogFingerprint.ts | 148 ++++++++++++++++++ docs/user/composer.md | 2 + 20 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/provider/skillCatalogFingerprint.test.ts create mode 100644 apps/server/src/provider/skillCatalogFingerprint.ts diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1141ac5856fc..1b089d2c70a4 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -59,7 +59,11 @@ import { } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; +import { + discoverAntigravitySkills, + listAntigravitySkillCatalogRoots, + resolveAntigravityUserHome, +} from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -471,6 +475,14 @@ export const AntigravityDriver: ProviderDriver + Effect.succeed( + listAntigravitySkillCatalogRoots({ + path, + cwd, + userHome, + }), + ), adapter, textGeneration, auth: authFlow.controller, diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index f9627e72fb55..4b941295313e 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -48,6 +48,24 @@ export function antigravityUserSkillDirectories( ]; } +export function listAntigravitySkillCatalogRoots(input: { + readonly path: Path.Path; + readonly cwd: string; + readonly userHome: string; +}): ReadonlyArray { + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + input.path, + input.path.join(input.userHome, ".gemini"), + ); + return [ + configSkills, + input.path.resolve(input.cwd, ".gemini", "skills"), + cliSkills, + input.path.resolve(input.cwd, ".agents", "skills"), + input.path.resolve(input.cwd, ".agent", "skills"), + ]; +} + const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b87a89dcf528..5b6c85e45ef6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -60,7 +60,7 @@ import { type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, listClaudeSkillCatalogRoots } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -262,6 +262,10 @@ export const ClaudeDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogRoots: (workspaceCwd) => + listClaudeSkillCatalogRoots(effectiveConfig, workspaceCwd, processEnv).pipe( + Effect.provideService(Path.Path, path), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index d126a15c12b8..f6f6b8642eb2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -5,7 +5,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; +import { + discoverClaudeSkills, + listClaudeSkillCatalogRoots, + skillOverrideSettingsPaths, +} from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -221,6 +225,20 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("lists the user and project skill directories Claude actually loads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-roots-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + assert.deepEqual(yield* listClaudeSkillCatalogRoots({ homePath: configDir }, workspace), [ + path.join(configDir, "skills"), + path.join(workspace, ".claude", "skills"), + ]); + }), + ); + it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 259ceeb4b775..8f244d1416a5 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -295,6 +295,21 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(NodeOS.homedir(), ".claude"); }); +/** + * User and project skill directories Claude Code actually loads. Fingerprinted + * by the registry so a new folder under either root refreshes the `$` picker + * without a full provider probe. + */ +export const listClaudeSkillCatalogRoots = Effect.fn("listClaudeSkillCatalogRoots")(function* ( + config: Pick, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return, never, Path.Path> { + const path = yield* Path.Path; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); + return [path.join(configDirPath, "skills"), path.join(cwd, ".claude", "skills")]; +}); + /** * Enumerate Claude Code skills from the user config dir and the workspace * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 22dd047f5c69..94ce2d8fe0dd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -68,6 +68,7 @@ import { } from "../providerUpdateSettings.ts"; import { codexContinuationIdentity, + listCodexSkillCatalogRoots, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; @@ -346,6 +347,10 @@ export const CodexDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogRoots: (cwd) => + Effect.succeed( + listCodexSkillCatalogRoots(pathService, homeLayout.sharedHomePath, cwd, processEnv), + ), consumeResetCredit, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 8efd0b5d994a..52029abd232c 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -33,6 +33,22 @@ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); +export function listCodexSkillCatalogRoots( + path: Path.Path, + sharedHomePath: string, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + return [ + path.join(sharedHomePath, "skills"), + path.join(sharedHomePath, "plugins"), + path.join(userHome, ".agents", "skills"), + path.join(cwd, ".codex", "skills"), + path.join(cwd, ".agents", "skills"), + ]; +} + function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = value && value.trim().length > 0 diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 70af46ff6867..1f60d7dac9c7 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -55,7 +55,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts"; +import { + discoverCursorSkills, + listCursorSkillCatalogRoots, + probeCursorSkills, +} from "./CursorSkills.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -234,6 +238,8 @@ export const CursorDriver: ProviderDriver = { ), Effect.flatMap((skills) => snapshotForCwd(cwd, skills)), ), + skillCatalogRoots: (cwd) => + Effect.succeed(listCursorSkillCatalogRoots(path, cwd, processEnv)), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index c8cd561dfea0..e67849e1da92 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -216,11 +216,11 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( return skills; }); -const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( - cwd?: string, - environment: NodeJS.ProcessEnv = process.env, -) { - const path = yield* Path.Path; +function cursorSkillRootSpecs( + path: Path.Path, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +): ReadonlyArray<{ readonly directory: string; readonly scope: "user" | "project" }> { const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); const rootsBelow = (base: string, scope: "user" | "project") => [ { directory: path.join(base, ".cursor", "skills"), scope }, @@ -228,7 +228,23 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( { directory: path.join(base, ".codex", "skills"), scope }, { directory: path.join(base, ".claude", "skills"), scope }, ]; - const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; + return [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; +} + +export function listCursorSkillCatalogRoots( + path: Path.Path, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + return cursorSkillRootSpecs(path, cwd, environment).map((root) => root.directory); +} + +const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const path = yield* Path.Path; + const roots = cursorSkillRootSpecs(path, cwd, environment); const skillsByName = new Map(); const budget: CursorSkillScanBudget = { diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5f0cf4d90c71..9bbaa98a703a 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -28,7 +28,7 @@ import { } from "../ProviderDriver.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills, listGrokSkillCatalogRoots } from "./GrokSkills.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -164,6 +164,8 @@ export const GrokDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogRoots: (workspaceCwd) => + Effect.succeed(listGrokSkillCatalogRoots(path, workspaceCwd, processEnv)), 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 ce8a31b51985..fb6da8fbdb94 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,10 +1,12 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills, listGrokSkillCatalogRoots } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); @@ -28,6 +30,26 @@ const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array { + it.effect("includes grok home, plugins, shared agents, and workspace roots", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect( + listGrokSkillCatalogRoots(path, "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/workspaces/demo", { + GROK_HOME: "/opt/grok-data", + HOME: "/home/dev", + }), + ).toEqual([ + path.join("/opt/grok-data", "skills"), + path.join("/opt/grok-data", "installed-plugins"), + path.join("/home/dev", ".agents", "skills"), + path.join("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/workspaces/demo", ".grok", "skills"), + path.join("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/workspaces/demo", ".agents", "skills"), + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + describe("discoverGrokSkills", () => { it.effect("maps inspect entries onto provider skills, sorted by name", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index 36391e3d2008..d835752685ed 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -15,9 +15,12 @@ * * @module provider/Drivers/GrokSkills */ +import * as NodeOS from "node:os"; + import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcess } from "effect/unstable/process"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -26,6 +29,27 @@ import { spawnAndCollect } from "../providerSnapshot.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; +/** + * Directories `grok inspect` can surface skills from. Plugin skills live + * under `installed-plugins`, which a flat `skills/` scan would miss, so that + * tree is fingerprinted too. + */ +export function listGrokSkillCatalogRoots( + path: Path.Path, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const grokHome = environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"); + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + return [ + path.join(grokHome, "skills"), + path.join(grokHome, "installed-plugins"), + path.join(userHome, ".agents", "skills"), + path.join(cwd, ".grok", "skills"), + path.join(cwd, ".agents", "skills"), + ]; +} + class GrokSkillsProbeError extends Schema.TaggedError()( "GrokSkillsProbeError", { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 0d874b9ceba8..200161093fd1 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,6 +12,8 @@ * * @module provider/Drivers/OpenCodeDriver */ +import * as NodeOS from "node:os"; + import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -61,6 +63,22 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); +export function listOpenCodeSkillCatalogRoots( + path: Path.Path, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + const configHome = environment.XDG_CONFIG_HOME?.trim() || path.join(userHome, ".config"); + return [ + path.join(cwd, ".opencode", "skills"), + path.join(cwd, ".agents", "skills"), + path.join(configHome, "opencode", "skills"), + path.join(userHome, ".opencode", "skills"), + path.join(userHome, ".agents", "skills"), + ]; +} + function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -287,6 +305,8 @@ export const OpenCodeDriver: ProviderDriver }), ), ), + skillCatalogRoots: (cwd) => + Effect.succeed(listOpenCodeSkillCatalogRoots(pathService, cwd, processEnv)), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..d6e7eb44f92d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1590,6 +1590,124 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("replaces a cached workspace skill snapshot when force-refreshing", () => + 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: [], + skills: [{ name: "global", path: "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/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const firstScoped = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + skills: [{ name: "review", path: "/workspace/review/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const secondScoped = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + skills: [ + { name: "review", path: "/workspace/review/SKILL.md", enabled: true }, + { name: "deploy", path: "/workspace/deploy/SKILL.md", enabled: true }, + ], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const scopedSnapshot = yield* Ref.make(firstScoped); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(machineProvider), + refresh: Effect.succeed(machineProvider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd: () => + Ref.update(snapshotCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(scopedSnapshot)), + ), + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const registryChanges = yield* PubSub.unbounded(); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + 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-skill-refresh-", + }), + ), + 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* Ref.get(snapshotCalls), 1); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, + firstScoped.skills, + ); + + yield* Ref.set(scopedSnapshot, secondScoped); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 1); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, + firstScoped.skills, + ); + + yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/workspace", + force: true, + }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, + secondScoped.skills, + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("refreshes OpenCode catalogs and preserves other providers", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..cc706a24e716 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -25,7 +25,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, type ServerProviderUpdateState, } from "@t3tools/contracts"; @@ -37,6 +37,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -54,6 +55,13 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + fingerprintSkillCatalogRoots, + isSkillCatalogTargetForInstance, + refreshChangedSkillCatalogs, + skillCatalogTargetKey, + SKILL_CATALOG_REFRESH_INTERVAL, +} from "../skillCatalogFingerprint.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -360,6 +368,7 @@ export const ProviderRegistryLive = Layer.effect( const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); + const skillFingerprintsRef = yield* Ref.make>(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -646,6 +655,17 @@ export const ProviderRegistryLive = Layer.effect( .filter((instanceId) => previousSubs.has(instanceId)), ); if (rebuiltInstanceIds.size > 0) { + yield* Ref.update(skillFingerprintsRef, (fingerprints) => { + const next = new Map(fingerprints); + for (const instanceId of rebuiltInstanceIds) { + for (const key of next.keys()) { + if (isSkillCatalogTargetForInstance(key, instanceId)) { + next.delete(key); + } + } + } + return next; + }); const [previousProviders, providers] = yield* Ref.modify( providersRef, (previousProviders) => { @@ -726,6 +746,19 @@ export const ProviderRegistryLive = Layer.effect( } return next; }); + yield* Ref.update(skillFingerprintsRef, (fingerprints) => { + const next = new Map(fingerprints); + for (const key of fingerprints.keys()) { + const instanceId = key.split("\0")[0]; + if ( + instanceId === undefined || + !knownInstanceIds.has(ProviderInstanceId.make(instanceId)) + ) { + next.delete(key); + } + } + return next; + }); }), ); const syncLiveSourcesAndContinue = syncLiveSources.pipe( @@ -803,17 +836,32 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const rememberSkillCatalogFingerprint = Effect.fn("rememberSkillCatalogFingerprint")(function* ( + instance: ProviderInstance, + cwd: string, + ) { + if (!instance.skillCatalogRoots) return; + const roots = yield* instance.skillCatalogRoots(cwd); + const fingerprint = yield* fingerprintSkillCatalogRoots(roots).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + yield* Ref.update(skillFingerprintsRef, (fingerprints) => + new Map(fingerprints).set(skillCatalogTargetKey(instance.instanceId, cwd), fingerprint), + ); + }); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; + readonly force?: boolean; }) { 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) - ) { + const hasWorkspaceSnapshot = Boolean( + provider?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd), + ); + if (!provider || !provider.enabled || (hasWorkspaceSnapshot && !input.force)) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -836,7 +884,10 @@ export const ProviderRegistryLive = Layer.effect( return Ref.modify(providersRef, (currentProviders) => { const nextProviders = currentProviders.map((candidate) => candidate.instanceId === input.instanceId && - !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + (input.force || + !candidate.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === input.cwd, + )) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, ); @@ -847,6 +898,7 @@ export const ProviderRegistryLive = Layer.effect( ? PubSub.publish(changesPubSub, nextProviders) : Effect.void, ), + Effect.tap(() => rememberSkillCatalogFingerprint(instance, input.cwd)), Effect.map(([, nextProviders]) => nextProviders), ); }), @@ -865,6 +917,63 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const refreshStaleSkillCatalogs = Effect.fn("refreshStaleSkillCatalogs")(function* () { + const providers = yield* Ref.get(providersRef); + const instances = yield* instanceRegistry.listInstances; + const instanceById = new Map( + instances.map((instance) => [instance.instanceId, instance] as const), + ); + const targets = yield* Effect.forEach( + providers.flatMap((provider) => { + const instance = instanceById.get(provider.instanceId); + const listRoots = instance?.skillCatalogRoots; + if ( + !provider.enabled || + !instance?.snapshotForCwd || + !listRoots || + !provider.workspaceSnapshots?.length + ) { + return []; + } + return provider.workspaceSnapshots.map((snapshot) => ({ + instance, + listRoots, + cwd: snapshot.cwd, + })); + }), + ({ instance, listRoots, cwd }) => + listRoots(cwd).pipe( + Effect.map((roots) => ({ + key: skillCatalogTargetKey(instance.instanceId, cwd), + roots, + refresh: refreshWorkspaceSnapshot({ + instanceId: instance.instanceId, + cwd, + force: true, + }).pipe(Effect.catchCause(recoverRefreshFailure), Effect.asVoid), + })), + ), + { concurrency: "unbounded" }, + ); + yield* refreshChangedSkillCatalogs({ + targets: targets.filter((target) => target.roots.length > 0), + fingerprints: skillFingerprintsRef, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + }); + + yield* Effect.sleep(SKILL_CATALOG_REFRESH_INTERVAL).pipe( + Effect.andThen( + refreshStaleSkillCatalogs().pipe( + Effect.repeat(Schedule.spaced(SKILL_CATALOG_REFRESH_INTERVAL)), + ), + ), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..1b2488b3ce04 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -73,6 +73,13 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + /** + * Directories (and occasional files) whose listing/mtime is a cheap stand-in + * for "this instance's skill catalog may have changed". The registry + * fingerprints these on a timer and re-runs `snapshotForCwd` only on a + * mismatch, so the `$` picker picks up new skills without a restart. + */ + readonly skillCatalogRoots?: (cwd: string) => Effect.Effect>; readonly refreshModels?: () => Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index bd917834e972..b58a4fb8876b 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -51,6 +51,12 @@ export interface ProviderRegistryShape { readonly refreshWorkspaceSnapshot: (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; + /** + * Re-probe even when a snapshot for `cwd` already exists. Used by the + * skill-catalog poller after a directory fingerprint changes. The + * composer RPC omits this so the first snapshot stays a one-shot. + */ + readonly force?: boolean; }) => Effect.Effect>; /** diff --git a/apps/server/src/provider/skillCatalogFingerprint.test.ts b/apps/server/src/provider/skillCatalogFingerprint.test.ts new file mode 100644 index 000000000000..ddedb13b4911 --- /dev/null +++ b/apps/server/src/provider/skillCatalogFingerprint.test.ts @@ -0,0 +1,101 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import { + fingerprintSkillCatalogRoots, + refreshChangedSkillCatalogs, +} from "./skillCatalogFingerprint.ts"; + +const writeSkill = Effect.fn(function* (skillsDir: string, name: string, body = "# skill") { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, name); + yield* fileSystem.makeDirectory(skillDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDir, "SKILL.md"), body); +}); + +it.layer(NodeServices.layer)("skillCatalogFingerprint", (it) => { + it.effect("is stable for missing roots and changes when a skill folder appears", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-fingerprint-", + }); + const skillsDir = path.join(tempDir, "skills"); + const missingDir = path.join(tempDir, "absent"); + + const emptyFingerprint = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + const again = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + assert.strictEqual(again, emptyFingerprint); + + yield* writeSkill(skillsDir, "review"); + const withSkill = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + assert.notStrictEqual(withSkill, emptyFingerprint); + + yield* writeSkill(skillsDir, "deploy"); + const withTwo = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + assert.notStrictEqual(withTwo, withSkill); + }), + ); + + it.effect("changes when SKILL.md contents are rewritten", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-fingerprint-edit-", + }); + const skillsDir = path.join(tempDir, "skills"); + yield* writeSkill(skillsDir, "review", "---\ndescription: one\n---\n"); + const before = yield* fingerprintSkillCatalogRoots([skillsDir]); + yield* fileSystem.writeFileString( + path.join(skillsDir, "review", "SKILL.md"), + "---\ndescription: updated\n---\n", + ); + const after = yield* fingerprintSkillCatalogRoots([skillsDir]); + assert.notStrictEqual(after, before); + }), + ); + + it.effect("seeds on first observation and refreshes only after a catalog change", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-fingerprint-refresh-", + }); + const skillsDir = path.join(tempDir, "skills"); + yield* fileSystem.makeDirectory(skillsDir, { recursive: true }); + const refreshCalls = yield* Ref.make(0); + const fingerprints = yield* Ref.make>(new Map()); + const tick = refreshChangedSkillCatalogs({ + targets: [ + { + key: "codex\0/workspace", + roots: [skillsDir], + refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(Effect.asVoid), + }, + ], + fingerprints, + }); + + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 0); + + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 0); + + yield* writeSkill(skillsDir, "review"); + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 1); + + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 1); + }), + ); +}); diff --git a/apps/server/src/provider/skillCatalogFingerprint.ts b/apps/server/src/provider/skillCatalogFingerprint.ts new file mode 100644 index 000000000000..d409a0c8d498 --- /dev/null +++ b/apps/server/src/provider/skillCatalogFingerprint.ts @@ -0,0 +1,148 @@ +/** + * Skill catalog fingerprint — a cheap directory signature used to decide + * whether the `$` picker needs a fresh skill probe. + * + * Workspace snapshots cache skills after the first `snapshotForCwd` and + * never look again, so a skill added while T3 is running stays invisible + * until restart. Re-running discovery on a timer is too expensive (Grok + * `inspect`, Codex `skills/list`, OpenCode server round-trips). This walk + * only readdirs and stats `SKILL.md` files, with a small directory budget, + * and the caller re-probes only when the signature changes. + * + * @module provider/skillCatalogFingerprint + */ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +export const SKILL_CATALOG_REFRESH_INTERVAL = Duration.seconds(1); + +const MAX_DIRECTORY_VISITS = 256; +const SKIP_ENTRY_NAMES = new Set([".DS_Store", ".git", "node_modules", "Thumbs.db"]); + +export function skillCatalogTargetKey(instanceId: string, cwd: string): string { + return `${instanceId}\0${cwd}`; +} + +export function isSkillCatalogTargetForInstance(key: string, instanceId: string): boolean { + return key.startsWith(`${instanceId}\0`); +} + +function entryMtimeMs(mtime: Option.Option): string { + const value = Option.getOrNull(mtime); + return value === null ? "0" : String(value.getTime()); +} + +/** + * Stable signature of the skill roots a provider reads. Missing paths are + * part of the signature so creating a previously absent `skills` directory + * still invalidates. Directory listings catch add/remove; `SKILL.md` mtime + * and size catch edits. Other files are listed by name only so a new script + * is visible without reading it. + */ +export const fingerprintSkillCatalogRoots = Effect.fn("fingerprintSkillCatalogRoots")(function* ( + directories: ReadonlyArray, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parts: string[] = []; + const queue = [...new Set(directories)].sort(); + let visited = 0; + + const readInfo = (target: string) => + fileSystem.stat(target).pipe(Effect.orElseSucceed(() => undefined)); + + while (queue.length > 0) { + const directory = queue.shift(); + if (directory === undefined) break; + visited += 1; + if (visited > MAX_DIRECTORY_VISITS) { + parts.push("#truncated"); + break; + } + + const info = yield* readInfo(directory); + if (info === undefined) { + parts.push(`${directory}:!`); + continue; + } + if (info.type === "File") { + parts.push(`${directory}:file:${entryMtimeMs(info.mtime)}:${String(info.size)}`); + continue; + } + if (info.type !== "Directory") { + parts.push(`${directory}:${info.type}`); + continue; + } + + const names = (yield* fileSystem + .readDirectory(directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) + .filter((name) => !SKIP_ENTRY_NAMES.has(name)) + .sort(); + parts.push(`${directory}:[${names.join(",")}]`); + + for (const name of names) { + const child = path.join(directory, name); + const childInfo = yield* readInfo(child); + if (childInfo === undefined) continue; + if (childInfo.type === "File") { + if (name === "SKILL.md") { + parts.push(`${child}:${entryMtimeMs(childInfo.mtime)}:${String(childInfo.size)}`); + } + continue; + } + if (childInfo.type === "Directory") { + queue.push(child); + } + } + } + + return parts.join("|"); +}); + +export interface SkillCatalogRefreshTarget { + readonly key: string; + readonly roots: ReadonlyArray; + readonly refresh: Effect.Effect; +} + +/** + * Compare each target's current fingerprint to the last one we stored. + * The first observation only seeds the map so opening a thread does not + * immediately re-run discovery. A later mismatch is the invalidation + * signal; `refresh` then re-probes that workspace snapshot. + */ +export const refreshChangedSkillCatalogs = Effect.fn("refreshChangedSkillCatalogs")( + function* (input: { + readonly targets: ReadonlyArray; + readonly fingerprints: Ref.Ref>; + }) { + const observed = yield* Effect.forEach( + input.targets, + (target) => + fingerprintSkillCatalogRoots(target.roots).pipe( + Effect.map((fingerprint) => [target, fingerprint] as const), + ), + { concurrency: "unbounded" }, + ); + + for (const [target, fingerprint] of observed) { + const previous = (yield* Ref.get(input.fingerprints)).get(target.key); + if (previous === undefined) { + yield* Ref.update(input.fingerprints, (fingerprints) => + new Map(fingerprints).set(target.key, fingerprint), + ); + continue; + } + if (previous === fingerprint) continue; + yield* target.refresh; + yield* Ref.update(input.fingerprints, (fingerprints) => + new Map(fingerprints).set(target.key, fingerprint), + ); + } + }, +); diff --git a/docs/user/composer.md b/docs/user/composer.md index fb51bd50754c..b7aeb049233e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -148,6 +148,8 @@ provider. On mobile, both are also available before starting a thread on The slash menu also includes skills unless you turn off **Settings → General → Show skills in slash menu**. Only skills enabled for the provider are listed. +Skills you add to a provider's skill directories appear in the picker without +restarting T3 Code. Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. From c2a05db1e042b29ebd36e9c4b58a0c40b650f960 Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Sat, 19 Sep 2026 21:50:05 -0700 Subject: [PATCH 2/3] fix(composer): refresh skills when the picker opens Workspace skill snapshots stayed cached after the first probe. Opening $ or / now force-reprobes that cwd instead of fingerprint-polling every provider's skill roots. Co-authored-by: Cursor --- .../threads/use-composer-command-menu.ts | 16 ++ .../src/provider/Drivers/AntigravityDriver.ts | 14 +- .../src/provider/Drivers/AntigravitySkills.ts | 18 --- .../src/provider/Drivers/ClaudeDriver.ts | 6 +- .../src/provider/Drivers/ClaudeSkills.test.ts | 20 +-- .../src/provider/Drivers/ClaudeSkills.ts | 15 -- .../src/provider/Drivers/CodexDriver.ts | 5 - .../src/provider/Drivers/CodexHomeLayout.ts | 16 -- .../src/provider/Drivers/CursorDriver.ts | 8 +- .../src/provider/Drivers/CursorSkills.ts | 8 - .../server/src/provider/Drivers/GrokDriver.ts | 4 +- .../src/provider/Drivers/GrokSkills.test.ts | 24 +-- .../server/src/provider/Drivers/GrokSkills.ts | 24 --- .../src/provider/Drivers/OpenCodeDriver.ts | 20 --- .../src/provider/Layers/ProviderRegistry.ts | 106 ------------- apps/server/src/provider/ProviderDriver.ts | 7 - .../src/provider/Services/ProviderRegistry.ts | 5 +- .../provider/skillCatalogFingerprint.test.ts | 101 ------------ .../src/provider/skillCatalogFingerprint.ts | 148 ------------------ apps/server/src/ws.ts | 1 + apps/web/src/components/chat/ChatComposer.tsx | 20 ++- docs/user/composer.md | 2 - packages/client-runtime/src/state/server.ts | 1 + packages/contracts/src/rpc.ts | 5 + packages/shared/src/composerTrigger.test.ts | 20 ++- packages/shared/src/composerTrigger.ts | 7 + 26 files changed, 76 insertions(+), 545 deletions(-) delete mode 100644 apps/server/src/provider/skillCatalogFingerprint.test.ts delete mode 100644 apps/server/src/provider/skillCatalogFingerprint.ts 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 b0b2cd42ecba..2cdc7d63c8e0 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -16,6 +16,7 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + composerTriggerRefreshesSkillCatalog, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -296,6 +297,21 @@ export function useComposerCommandMenu({ } return detectComposerTrigger(draftMessage, selection.end); }, [draftMessage, enabled, selection]); + const skillCatalogRefreshKeyRef = useRef(null); + useEffect(() => { + if (!composerTriggerRefreshesSkillCatalog(trigger?.kind)) { + skillCatalogRefreshKeyRef.current = null; + return; + } + if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; + const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; + if (skillCatalogRefreshKeyRef.current === key) return; + skillCatalogRefreshKeyRef.current = key; + void refreshProviders({ + environmentId, + input: { instanceId: selectedProviderInstanceId, cwd: projectCwd, force: true }, + }); + }, [environmentId, projectCwd, refreshProviders, selectedProviderInstanceId, trigger?.kind]); const pathSearch = useComposerPathSearch({ environmentId, cwd: trigger?.kind === "path" ? projectCwd : null, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1b089d2c70a4..1141ac5856fc 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -59,11 +59,7 @@ import { } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { - discoverAntigravitySkills, - listAntigravitySkillCatalogRoots, - resolveAntigravityUserHome, -} from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -475,14 +471,6 @@ export const AntigravityDriver: ProviderDriver - Effect.succeed( - listAntigravitySkillCatalogRoots({ - path, - cwd, - userHome, - }), - ), adapter, textGeneration, auth: authFlow.controller, diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 4b941295313e..f9627e72fb55 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -48,24 +48,6 @@ export function antigravityUserSkillDirectories( ]; } -export function listAntigravitySkillCatalogRoots(input: { - readonly path: Path.Path; - readonly cwd: string; - readonly userHome: string; -}): ReadonlyArray { - const [configSkills, cliSkills] = antigravityUserSkillDirectories( - input.path, - input.path.join(input.userHome, ".gemini"), - ); - return [ - configSkills, - input.path.resolve(input.cwd, ".gemini", "skills"), - cliSkills, - input.path.resolve(input.cwd, ".agents", "skills"), - input.path.resolve(input.cwd, ".agent", "skills"), - ]; -} - const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 5b6c85e45ef6..b87a89dcf528 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -60,7 +60,7 @@ import { type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; -import { discoverClaudeSkills, listClaudeSkillCatalogRoots } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -262,10 +262,6 @@ export const ClaudeDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, - skillCatalogRoots: (workspaceCwd) => - listClaudeSkillCatalogRoots(effectiveConfig, workspaceCwd, processEnv).pipe( - Effect.provideService(Path.Path, path), - ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index f6f6b8642eb2..d126a15c12b8 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -5,11 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { - discoverClaudeSkills, - listClaudeSkillCatalogRoots, - skillOverrideSettingsPaths, -} from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -225,20 +221,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("lists the user and project skill directories Claude actually loads", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-roots-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - assert.deepEqual(yield* listClaudeSkillCatalogRoots({ homePath: configDir }, workspace), [ - path.join(configDir, "skills"), - path.join(workspace, ".claude", "skills"), - ]); - }), - ); - it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 8f244d1416a5..259ceeb4b775 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -295,21 +295,6 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(NodeOS.homedir(), ".claude"); }); -/** - * User and project skill directories Claude Code actually loads. Fingerprinted - * by the registry so a new folder under either root refreshes the `$` picker - * without a full provider probe. - */ -export const listClaudeSkillCatalogRoots = Effect.fn("listClaudeSkillCatalogRoots")(function* ( - config: Pick, - cwd: string, - environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return, never, Path.Path> { - const path = yield* Path.Path; - const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); - return [path.join(configDirPath, "skills"), path.join(cwd, ".claude", "skills")]; -}); - /** * Enumerate Claude Code skills from the user config dir and the workspace * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 94ce2d8fe0dd..22dd047f5c69 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -68,7 +68,6 @@ import { } from "../providerUpdateSettings.ts"; import { codexContinuationIdentity, - listCodexSkillCatalogRoots, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; @@ -347,10 +346,6 @@ export const CodexDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, - skillCatalogRoots: (cwd) => - Effect.succeed( - listCodexSkillCatalogRoots(pathService, homeLayout.sharedHomePath, cwd, processEnv), - ), consumeResetCredit, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 52029abd232c..8efd0b5d994a 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -33,22 +33,6 @@ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); -export function listCodexSkillCatalogRoots( - path: Path.Path, - sharedHomePath: string, - cwd: string, - environment: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); - return [ - path.join(sharedHomePath, "skills"), - path.join(sharedHomePath, "plugins"), - path.join(userHome, ".agents", "skills"), - path.join(cwd, ".codex", "skills"), - path.join(cwd, ".agents", "skills"), - ]; -} - function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = value && value.trim().length > 0 diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 1f60d7dac9c7..70af46ff6867 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -55,11 +55,7 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { - discoverCursorSkills, - listCursorSkillCatalogRoots, - probeCursorSkills, -} from "./CursorSkills.ts"; +import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -238,8 +234,6 @@ export const CursorDriver: ProviderDriver = { ), Effect.flatMap((skills) => snapshotForCwd(cwd, skills)), ), - skillCatalogRoots: (cwd) => - Effect.succeed(listCursorSkillCatalogRoots(path, cwd, processEnv)), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index e67849e1da92..d42240acbe91 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -231,14 +231,6 @@ function cursorSkillRootSpecs( return [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; } -export function listCursorSkillCatalogRoots( - path: Path.Path, - cwd: string, - environment: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - return cursorSkillRootSpecs(path, cwd, environment).map((root) => root.directory); -} - const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( cwd?: string, environment: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 9bbaa98a703a..5f0cf4d90c71 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -28,7 +28,7 @@ import { } from "../ProviderDriver.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { discoverGrokSkills, listGrokSkillCatalogRoots } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -164,8 +164,6 @@ export const GrokDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, - skillCatalogRoots: (workspaceCwd) => - Effect.succeed(listGrokSkillCatalogRoots(path, workspaceCwd, processEnv)), 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 fb6da8fbdb94..ce8a31b51985 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,12 +1,10 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, listGrokSkillCatalogRoots } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); @@ -30,26 +28,6 @@ const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array { - it.effect("includes grok home, plugins, shared agents, and workspace roots", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect( - listGrokSkillCatalogRoots(path, "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/workspaces/demo", { - GROK_HOME: "/opt/grok-data", - HOME: "/home/dev", - }), - ).toEqual([ - path.join("/opt/grok-data", "skills"), - path.join("/opt/grok-data", "installed-plugins"), - path.join("/home/dev", ".agents", "skills"), - path.join("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/workspaces/demo", ".grok", "skills"), - path.join("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/workspaces/demo", ".agents", "skills"), - ]); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); - describe("discoverGrokSkills", () => { it.effect("maps inspect entries onto provider skills, sorted by name", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index d835752685ed..36391e3d2008 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -15,12 +15,9 @@ * * @module provider/Drivers/GrokSkills */ -import * as NodeOS from "node:os"; - import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcess } from "effect/unstable/process"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -29,27 +26,6 @@ import { spawnAndCollect } from "../providerSnapshot.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; -/** - * Directories `grok inspect` can surface skills from. Plugin skills live - * under `installed-plugins`, which a flat `skills/` scan would miss, so that - * tree is fingerprinted too. - */ -export function listGrokSkillCatalogRoots( - path: Path.Path, - cwd: string, - environment: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - const grokHome = environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"); - const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); - return [ - path.join(grokHome, "skills"), - path.join(grokHome, "installed-plugins"), - path.join(userHome, ".agents", "skills"), - path.join(cwd, ".grok", "skills"), - path.join(cwd, ".agents", "skills"), - ]; -} - class GrokSkillsProbeError extends Schema.TaggedError()( "GrokSkillsProbeError", { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 200161093fd1..0d874b9ceba8 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,8 +12,6 @@ * * @module provider/Drivers/OpenCodeDriver */ -import * as NodeOS from "node:os"; - import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -63,22 +61,6 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); -export function listOpenCodeSkillCatalogRoots( - path: Path.Path, - cwd: string, - environment: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); - const configHome = environment.XDG_CONFIG_HOME?.trim() || path.join(userHome, ".config"); - return [ - path.join(cwd, ".opencode", "skills"), - path.join(cwd, ".agents", "skills"), - path.join(configHome, "opencode", "skills"), - path.join(userHome, ".opencode", "skills"), - path.join(userHome, ".agents", "skills"), - ]; -} - function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -305,8 +287,6 @@ export const OpenCodeDriver: ProviderDriver }), ), ), - skillCatalogRoots: (cwd) => - Effect.succeed(listOpenCodeSkillCatalogRoots(pathService, cwd, processEnv)), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index cc706a24e716..3fa070330c33 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -37,7 +37,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -55,13 +54,6 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; -import { - fingerprintSkillCatalogRoots, - isSkillCatalogTargetForInstance, - refreshChangedSkillCatalogs, - skillCatalogTargetKey, - SKILL_CATALOG_REFRESH_INTERVAL, -} from "../skillCatalogFingerprint.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -368,7 +360,6 @@ export const ProviderRegistryLive = Layer.effect( const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); - const skillFingerprintsRef = yield* Ref.make>(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -655,17 +646,6 @@ export const ProviderRegistryLive = Layer.effect( .filter((instanceId) => previousSubs.has(instanceId)), ); if (rebuiltInstanceIds.size > 0) { - yield* Ref.update(skillFingerprintsRef, (fingerprints) => { - const next = new Map(fingerprints); - for (const instanceId of rebuiltInstanceIds) { - for (const key of next.keys()) { - if (isSkillCatalogTargetForInstance(key, instanceId)) { - next.delete(key); - } - } - } - return next; - }); const [previousProviders, providers] = yield* Ref.modify( providersRef, (previousProviders) => { @@ -746,19 +726,6 @@ export const ProviderRegistryLive = Layer.effect( } return next; }); - yield* Ref.update(skillFingerprintsRef, (fingerprints) => { - const next = new Map(fingerprints); - for (const key of fingerprints.keys()) { - const instanceId = key.split("\0")[0]; - if ( - instanceId === undefined || - !knownInstanceIds.has(ProviderInstanceId.make(instanceId)) - ) { - next.delete(key); - } - } - return next; - }); }), ); const syncLiveSourcesAndContinue = syncLiveSources.pipe( @@ -836,21 +803,6 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); - const rememberSkillCatalogFingerprint = Effect.fn("rememberSkillCatalogFingerprint")(function* ( - instance: ProviderInstance, - cwd: string, - ) { - if (!instance.skillCatalogRoots) return; - const roots = yield* instance.skillCatalogRoots(cwd); - const fingerprint = yield* fingerprintSkillCatalogRoots(roots).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - yield* Ref.update(skillFingerprintsRef, (fingerprints) => - new Map(fingerprints).set(skillCatalogTargetKey(instance.instanceId, cwd), fingerprint), - ); - }); - const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; @@ -898,7 +850,6 @@ export const ProviderRegistryLive = Layer.effect( ? PubSub.publish(changesPubSub, nextProviders) : Effect.void, ), - Effect.tap(() => rememberSkillCatalogFingerprint(instance, input.cwd)), Effect.map(([, nextProviders]) => nextProviders), ); }), @@ -917,63 +868,6 @@ export const ProviderRegistryLive = Layer.effect( ); }); - const refreshStaleSkillCatalogs = Effect.fn("refreshStaleSkillCatalogs")(function* () { - const providers = yield* Ref.get(providersRef); - const instances = yield* instanceRegistry.listInstances; - const instanceById = new Map( - instances.map((instance) => [instance.instanceId, instance] as const), - ); - const targets = yield* Effect.forEach( - providers.flatMap((provider) => { - const instance = instanceById.get(provider.instanceId); - const listRoots = instance?.skillCatalogRoots; - if ( - !provider.enabled || - !instance?.snapshotForCwd || - !listRoots || - !provider.workspaceSnapshots?.length - ) { - return []; - } - return provider.workspaceSnapshots.map((snapshot) => ({ - instance, - listRoots, - cwd: snapshot.cwd, - })); - }), - ({ instance, listRoots, cwd }) => - listRoots(cwd).pipe( - Effect.map((roots) => ({ - key: skillCatalogTargetKey(instance.instanceId, cwd), - roots, - refresh: refreshWorkspaceSnapshot({ - instanceId: instance.instanceId, - cwd, - force: true, - }).pipe(Effect.catchCause(recoverRefreshFailure), Effect.asVoid), - })), - ), - { concurrency: "unbounded" }, - ); - yield* refreshChangedSkillCatalogs({ - targets: targets.filter((target) => target.roots.length > 0), - fingerprints: skillFingerprintsRef, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - }); - - yield* Effect.sleep(SKILL_CATALOG_REFRESH_INTERVAL).pipe( - Effect.andThen( - refreshStaleSkillCatalogs().pipe( - Effect.repeat(Schedule.spaced(SKILL_CATALOG_REFRESH_INTERVAL)), - ), - ), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - ); - return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 1b2488b3ce04..a5c092233052 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -73,13 +73,6 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; - /** - * Directories (and occasional files) whose listing/mtime is a cheap stand-in - * for "this instance's skill catalog may have changed". The registry - * fingerprints these on a timer and re-runs `snapshotForCwd` only on a - * mismatch, so the `$` picker picks up new skills without a restart. - */ - readonly skillCatalogRoots?: (cwd: string) => Effect.Effect>; readonly refreshModels?: () => Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b58a4fb8876b..57046ec4df3e 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -52,9 +52,8 @@ export interface ProviderRegistryShape { readonly instanceId: ProviderInstanceId; readonly cwd: string; /** - * Re-probe even when a snapshot for `cwd` already exists. Used by the - * skill-catalog poller after a directory fingerprint changes. The - * composer RPC omits this so the first snapshot stays a one-shot. + * Re-probe even when a snapshot for `cwd` already exists. The composer + * sends this when the skill or slash menu opens. */ readonly force?: boolean; }) => Effect.Effect>; diff --git a/apps/server/src/provider/skillCatalogFingerprint.test.ts b/apps/server/src/provider/skillCatalogFingerprint.test.ts deleted file mode 100644 index ddedb13b4911..000000000000 --- a/apps/server/src/provider/skillCatalogFingerprint.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; - -import { - fingerprintSkillCatalogRoots, - refreshChangedSkillCatalogs, -} from "./skillCatalogFingerprint.ts"; - -const writeSkill = Effect.fn(function* (skillsDir: string, name: string, body = "# skill") { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const skillDir = path.join(skillsDir, name); - yield* fileSystem.makeDirectory(skillDir, { recursive: true }); - yield* fileSystem.writeFileString(path.join(skillDir, "SKILL.md"), body); -}); - -it.layer(NodeServices.layer)("skillCatalogFingerprint", (it) => { - it.effect("is stable for missing roots and changes when a skill folder appears", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-skill-fingerprint-", - }); - const skillsDir = path.join(tempDir, "skills"); - const missingDir = path.join(tempDir, "absent"); - - const emptyFingerprint = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); - const again = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); - assert.strictEqual(again, emptyFingerprint); - - yield* writeSkill(skillsDir, "review"); - const withSkill = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); - assert.notStrictEqual(withSkill, emptyFingerprint); - - yield* writeSkill(skillsDir, "deploy"); - const withTwo = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); - assert.notStrictEqual(withTwo, withSkill); - }), - ); - - it.effect("changes when SKILL.md contents are rewritten", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-skill-fingerprint-edit-", - }); - const skillsDir = path.join(tempDir, "skills"); - yield* writeSkill(skillsDir, "review", "---\ndescription: one\n---\n"); - const before = yield* fingerprintSkillCatalogRoots([skillsDir]); - yield* fileSystem.writeFileString( - path.join(skillsDir, "review", "SKILL.md"), - "---\ndescription: updated\n---\n", - ); - const after = yield* fingerprintSkillCatalogRoots([skillsDir]); - assert.notStrictEqual(after, before); - }), - ); - - it.effect("seeds on first observation and refreshes only after a catalog change", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-skill-fingerprint-refresh-", - }); - const skillsDir = path.join(tempDir, "skills"); - yield* fileSystem.makeDirectory(skillsDir, { recursive: true }); - const refreshCalls = yield* Ref.make(0); - const fingerprints = yield* Ref.make>(new Map()); - const tick = refreshChangedSkillCatalogs({ - targets: [ - { - key: "codex\0/workspace", - roots: [skillsDir], - refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(Effect.asVoid), - }, - ], - fingerprints, - }); - - yield* tick; - assert.strictEqual(yield* Ref.get(refreshCalls), 0); - - yield* tick; - assert.strictEqual(yield* Ref.get(refreshCalls), 0); - - yield* writeSkill(skillsDir, "review"); - yield* tick; - assert.strictEqual(yield* Ref.get(refreshCalls), 1); - - yield* tick; - assert.strictEqual(yield* Ref.get(refreshCalls), 1); - }), - ); -}); diff --git a/apps/server/src/provider/skillCatalogFingerprint.ts b/apps/server/src/provider/skillCatalogFingerprint.ts deleted file mode 100644 index d409a0c8d498..000000000000 --- a/apps/server/src/provider/skillCatalogFingerprint.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Skill catalog fingerprint — a cheap directory signature used to decide - * whether the `$` picker needs a fresh skill probe. - * - * Workspace snapshots cache skills after the first `snapshotForCwd` and - * never look again, so a skill added while T3 is running stays invisible - * until restart. Re-running discovery on a timer is too expensive (Grok - * `inspect`, Codex `skills/list`, OpenCode server round-trips). This walk - * only readdirs and stats `SKILL.md` files, with a small directory budget, - * and the caller re-probes only when the signature changes. - * - * @module provider/skillCatalogFingerprint - */ -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; - -export const SKILL_CATALOG_REFRESH_INTERVAL = Duration.seconds(1); - -const MAX_DIRECTORY_VISITS = 256; -const SKIP_ENTRY_NAMES = new Set([".DS_Store", ".git", "node_modules", "Thumbs.db"]); - -export function skillCatalogTargetKey(instanceId: string, cwd: string): string { - return `${instanceId}\0${cwd}`; -} - -export function isSkillCatalogTargetForInstance(key: string, instanceId: string): boolean { - return key.startsWith(`${instanceId}\0`); -} - -function entryMtimeMs(mtime: Option.Option): string { - const value = Option.getOrNull(mtime); - return value === null ? "0" : String(value.getTime()); -} - -/** - * Stable signature of the skill roots a provider reads. Missing paths are - * part of the signature so creating a previously absent `skills` directory - * still invalidates. Directory listings catch add/remove; `SKILL.md` mtime - * and size catch edits. Other files are listed by name only so a new script - * is visible without reading it. - */ -export const fingerprintSkillCatalogRoots = Effect.fn("fingerprintSkillCatalogRoots")(function* ( - directories: ReadonlyArray, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const parts: string[] = []; - const queue = [...new Set(directories)].sort(); - let visited = 0; - - const readInfo = (target: string) => - fileSystem.stat(target).pipe(Effect.orElseSucceed(() => undefined)); - - while (queue.length > 0) { - const directory = queue.shift(); - if (directory === undefined) break; - visited += 1; - if (visited > MAX_DIRECTORY_VISITS) { - parts.push("#truncated"); - break; - } - - const info = yield* readInfo(directory); - if (info === undefined) { - parts.push(`${directory}:!`); - continue; - } - if (info.type === "File") { - parts.push(`${directory}:file:${entryMtimeMs(info.mtime)}:${String(info.size)}`); - continue; - } - if (info.type !== "Directory") { - parts.push(`${directory}:${info.type}`); - continue; - } - - const names = (yield* fileSystem - .readDirectory(directory) - .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) - .filter((name) => !SKIP_ENTRY_NAMES.has(name)) - .sort(); - parts.push(`${directory}:[${names.join(",")}]`); - - for (const name of names) { - const child = path.join(directory, name); - const childInfo = yield* readInfo(child); - if (childInfo === undefined) continue; - if (childInfo.type === "File") { - if (name === "SKILL.md") { - parts.push(`${child}:${entryMtimeMs(childInfo.mtime)}:${String(childInfo.size)}`); - } - continue; - } - if (childInfo.type === "Directory") { - queue.push(child); - } - } - } - - return parts.join("|"); -}); - -export interface SkillCatalogRefreshTarget { - readonly key: string; - readonly roots: ReadonlyArray; - readonly refresh: Effect.Effect; -} - -/** - * Compare each target's current fingerprint to the last one we stored. - * The first observation only seeds the map so opening a thread does not - * immediately re-run discovery. A later mismatch is the invalidation - * signal; `refresh` then re-probes that workspace snapshot. - */ -export const refreshChangedSkillCatalogs = Effect.fn("refreshChangedSkillCatalogs")( - function* (input: { - readonly targets: ReadonlyArray; - readonly fingerprints: Ref.Ref>; - }) { - const observed = yield* Effect.forEach( - input.targets, - (target) => - fingerprintSkillCatalogRoots(target.roots).pipe( - Effect.map((fingerprint) => [target, fingerprint] as const), - ), - { concurrency: "unbounded" }, - ); - - for (const [target, fingerprint] of observed) { - const previous = (yield* Ref.get(input.fingerprints)).get(target.key); - if (previous === undefined) { - yield* Ref.update(input.fingerprints, (fingerprints) => - new Map(fingerprints).set(target.key, fingerprint), - ); - continue; - } - if (previous === fingerprint) continue; - yield* target.refresh; - yield* Ref.update(input.fingerprints, (fingerprints) => - new Map(fingerprints).set(target.key, fingerprint), - ); - } - }, -); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d118bd3d012c..8194bb2aef0a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2330,6 +2330,7 @@ const makeWsRpcLayer = ( ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, cwd: input.cwd, + ...(input.force === true ? { force: true } : {}), }) : input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 255f61a0c752..b7ca751e3437 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -48,7 +48,10 @@ import { pastedTextDisposition, wouldTextPasteExceedLimit, } from "@t3tools/client-runtime/text-paste"; -import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { + composerTriggerRefreshesSkillCatalog, + serializeComposerFileLink, +} from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -2213,6 +2216,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Derived: composer trigger / menu // ------------------------------------------------------------------ const composerTriggerKind = composerTrigger?.kind ?? null; + const skillCatalogRefreshKeyRef = useRef(null); + useEffect(() => { + if (!composerTriggerRefreshesSkillCatalog(composerTriggerKind)) { + skillCatalogRefreshKeyRef.current = null; + return; + } + if (!gitCwd || !selectedProviderEntry) return; + const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; + if (skillCatalogRefreshKeyRef.current === key) return; + skillCatalogRefreshKeyRef.current = key; + void refreshProviders({ + environmentId, + input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd, force: true }, + }); + }, [composerTriggerKind, environmentId, gitCwd, refreshProviders, selectedProviderEntry]); const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const pullRequestTriggerQuery = composerTrigger?.kind === "pull-request" ? composerTrigger.query : ""; diff --git a/docs/user/composer.md b/docs/user/composer.md index b7aeb049233e..fb51bd50754c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -148,8 +148,6 @@ provider. On mobile, both are also available before starting a thread on The slash menu also includes skills unless you turn off **Settings → General → Show skills in slash menu**. Only skills enabled for the provider are listed. -Skills you add to a provider's skill directories appear in the picker without -restarting T3 Code. Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 18127076ff19..7464ec57968f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1069,6 +1069,7 @@ export function createServerEnvironmentAtoms( environmentId, input.instanceId ?? null, input.cwd ?? null, + input.force ?? false, input.refreshModels ?? false, ]), }, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41af6d9f1e9e..5e6c179d7e49 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -476,6 +476,11 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + /** + * Re-probe a cwd that already has a workspace snapshot. The composer + * sends this when the skill or slash menu opens. + */ + force: Schema.optional(Schema.Boolean), /** Explicit user request. Background status refreshes must not open agent sessions. */ refreshModels: Schema.optional(Schema.Boolean), }), diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 06b7038dad2b..ffeaccc2578f 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; +import { + composerTriggerRefreshesSkillCatalog, + detectComposerTrigger, + serializeComposerFileLink, +} from "./composerTrigger.ts"; describe("detectComposerTrigger", () => { it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( @@ -17,6 +21,20 @@ describe("detectComposerTrigger", () => { ); }); +describe("composerTriggerRefreshesSkillCatalog", () => { + it("re-probes when the skill or slash menu is open", () => { + expect(composerTriggerRefreshesSkillCatalog("skill")).toBe(true); + expect(composerTriggerRefreshesSkillCatalog("slash-command")).toBe(true); + }); + + it("leaves other composer triggers on the cached catalog", () => { + expect(composerTriggerRefreshesSkillCatalog("path")).toBe(false); + expect(composerTriggerRefreshesSkillCatalog("pull-request")).toBe(false); + expect(composerTriggerRefreshesSkillCatalog("slash-model")).toBe(false); + expect(composerTriggerRefreshesSkillCatalog(null)).toBe(false); + }); +}); + describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { expect(serializeComposerFileLink("path/to/package.json")).toBe( diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 6153978edb92..e5ebf3ffa83c 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -13,6 +13,13 @@ export interface ComposerTrigger { rangeEnd: number; } +/** `$` and `/` both list skills, so opening either menu re-probes the catalog. */ +export function composerTriggerRefreshesSkillCatalog( + kind: ComposerTriggerKind | null | undefined, +): boolean { + return kind === "skill" || kind === "slash-command"; +} + function composerFileLinkBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; From b537e62c65c991d71d212ee99b705e5a80952d50 Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Sat, 19 Sep 2026 21:59:46 -0700 Subject: [PATCH 3/3] fix(server): refresh skill catalogs when their roots change Opening the picker to re-probe was cheaper, but new skills stayed hidden until the user opened $ or /. Stat each listed root every 3s and, on a mismatch, drop that cwd snapshot so the existing one-shot refresh runs again. Missing paths stay in the signature. Co-authored-by: Cursor --- .../threads/use-composer-command-menu.ts | 16 -- .../src/provider/Drivers/AntigravityDriver.ts | 14 +- .../src/provider/Drivers/AntigravitySkills.ts | 18 ++ .../src/provider/Drivers/ClaudeDriver.ts | 6 +- .../src/provider/Drivers/ClaudeSkills.test.ts | 20 ++- .../src/provider/Drivers/ClaudeSkills.ts | 15 ++ .../src/provider/Drivers/CodexDriver.ts | 5 + .../src/provider/Drivers/CodexHomeLayout.ts | 16 ++ .../src/provider/Drivers/CursorDriver.ts | 8 +- .../src/provider/Drivers/CursorSkills.ts | 8 + .../server/src/provider/Drivers/GrokDriver.ts | 4 +- .../src/provider/Drivers/GrokSkills.test.ts | 24 ++- .../server/src/provider/Drivers/GrokSkills.ts | 24 +++ .../src/provider/Drivers/OpenCodeDriver.ts | 20 +++ .../provider/Layers/ProviderRegistry.test.ts | 118 ------------- .../src/provider/Layers/ProviderRegistry.ts | 160 +++++++++++++++++- apps/server/src/provider/ProviderDriver.ts | 7 + .../src/provider/Services/ProviderRegistry.ts | 5 - .../provider/skillCatalogFingerprint.test.ts | 84 +++++++++ .../src/provider/skillCatalogFingerprint.ts | 100 +++++++++++ apps/server/src/ws.ts | 1 - apps/web/src/components/chat/ChatComposer.tsx | 20 +-- docs/user/composer.md | 2 + packages/client-runtime/src/state/server.ts | 1 - packages/contracts/src/rpc.ts | 5 - packages/shared/src/composerTrigger.test.ts | 20 +-- packages/shared/src/composerTrigger.ts | 7 - 27 files changed, 522 insertions(+), 206 deletions(-) create mode 100644 apps/server/src/provider/skillCatalogFingerprint.test.ts create mode 100644 apps/server/src/provider/skillCatalogFingerprint.ts 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 2cdc7d63c8e0..b0b2cd42ecba 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -16,7 +16,6 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { - composerTriggerRefreshesSkillCatalog, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -297,21 +296,6 @@ export function useComposerCommandMenu({ } return detectComposerTrigger(draftMessage, selection.end); }, [draftMessage, enabled, selection]); - const skillCatalogRefreshKeyRef = useRef(null); - useEffect(() => { - if (!composerTriggerRefreshesSkillCatalog(trigger?.kind)) { - skillCatalogRefreshKeyRef.current = null; - return; - } - if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; - const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; - if (skillCatalogRefreshKeyRef.current === key) return; - skillCatalogRefreshKeyRef.current = key; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderInstanceId, cwd: projectCwd, force: true }, - }); - }, [environmentId, projectCwd, refreshProviders, selectedProviderInstanceId, trigger?.kind]); const pathSearch = useComposerPathSearch({ environmentId, cwd: trigger?.kind === "path" ? projectCwd : null, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1141ac5856fc..1b089d2c70a4 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -59,7 +59,11 @@ import { } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; +import { + discoverAntigravitySkills, + listAntigravitySkillCatalogRoots, + resolveAntigravityUserHome, +} from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -471,6 +475,14 @@ export const AntigravityDriver: ProviderDriver + Effect.succeed( + listAntigravitySkillCatalogRoots({ + path, + cwd, + userHome, + }), + ), adapter, textGeneration, auth: authFlow.controller, diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index f9627e72fb55..4b941295313e 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -48,6 +48,24 @@ export function antigravityUserSkillDirectories( ]; } +export function listAntigravitySkillCatalogRoots(input: { + readonly path: Path.Path; + readonly cwd: string; + readonly userHome: string; +}): ReadonlyArray { + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + input.path, + input.path.join(input.userHome, ".gemini"), + ); + return [ + configSkills, + input.path.resolve(input.cwd, ".gemini", "skills"), + cliSkills, + input.path.resolve(input.cwd, ".agents", "skills"), + input.path.resolve(input.cwd, ".agent", "skills"), + ]; +} + const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b87a89dcf528..5b6c85e45ef6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -60,7 +60,7 @@ import { type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, listClaudeSkillCatalogRoots } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -262,6 +262,10 @@ export const ClaudeDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogRoots: (workspaceCwd) => + listClaudeSkillCatalogRoots(effectiveConfig, workspaceCwd, processEnv).pipe( + Effect.provideService(Path.Path, path), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index d126a15c12b8..f6f6b8642eb2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -5,7 +5,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; +import { + discoverClaudeSkills, + listClaudeSkillCatalogRoots, + skillOverrideSettingsPaths, +} from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -221,6 +225,20 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("lists the user and project skill directories Claude actually loads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-roots-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + assert.deepEqual(yield* listClaudeSkillCatalogRoots({ homePath: configDir }, workspace), [ + path.join(configDir, "skills"), + path.join(workspace, ".claude", "skills"), + ]); + }), + ); + it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 259ceeb4b775..8f244d1416a5 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -295,6 +295,21 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(NodeOS.homedir(), ".claude"); }); +/** + * User and project skill directories Claude Code actually loads. Fingerprinted + * by the registry so a new folder under either root refreshes the `$` picker + * without a full provider probe. + */ +export const listClaudeSkillCatalogRoots = Effect.fn("listClaudeSkillCatalogRoots")(function* ( + config: Pick, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return, never, Path.Path> { + const path = yield* Path.Path; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); + return [path.join(configDirPath, "skills"), path.join(cwd, ".claude", "skills")]; +}); + /** * Enumerate Claude Code skills from the user config dir and the workspace * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 22dd047f5c69..94ce2d8fe0dd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -68,6 +68,7 @@ import { } from "../providerUpdateSettings.ts"; import { codexContinuationIdentity, + listCodexSkillCatalogRoots, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; @@ -346,6 +347,10 @@ export const CodexDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogRoots: (cwd) => + Effect.succeed( + listCodexSkillCatalogRoots(pathService, homeLayout.sharedHomePath, cwd, processEnv), + ), consumeResetCredit, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 8efd0b5d994a..52029abd232c 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -33,6 +33,22 @@ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); +export function listCodexSkillCatalogRoots( + path: Path.Path, + sharedHomePath: string, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + return [ + path.join(sharedHomePath, "skills"), + path.join(sharedHomePath, "plugins"), + path.join(userHome, ".agents", "skills"), + path.join(cwd, ".codex", "skills"), + path.join(cwd, ".agents", "skills"), + ]; +} + function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = value && value.trim().length > 0 diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 70af46ff6867..1f60d7dac9c7 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -55,7 +55,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { discoverCursorSkills, probeCursorSkills } from "./CursorSkills.ts"; +import { + discoverCursorSkills, + listCursorSkillCatalogRoots, + probeCursorSkills, +} from "./CursorSkills.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -234,6 +238,8 @@ export const CursorDriver: ProviderDriver = { ), Effect.flatMap((skills) => snapshotForCwd(cwd, skills)), ), + skillCatalogRoots: (cwd) => + Effect.succeed(listCursorSkillCatalogRoots(path, cwd, processEnv)), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index d42240acbe91..e67849e1da92 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -231,6 +231,14 @@ function cursorSkillRootSpecs( return [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; } +export function listCursorSkillCatalogRoots( + path: Path.Path, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + return cursorSkillRootSpecs(path, cwd, environment).map((root) => root.directory); +} + const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( cwd?: string, environment: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5f0cf4d90c71..9bbaa98a703a 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -28,7 +28,7 @@ import { } from "../ProviderDriver.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills, listGrokSkillCatalogRoots } from "./GrokSkills.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, @@ -164,6 +164,8 @@ export const GrokDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogRoots: (workspaceCwd) => + Effect.succeed(listGrokSkillCatalogRoots(path, workspaceCwd, processEnv)), 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 ce8a31b51985..fb6da8fbdb94 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,10 +1,12 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills, listGrokSkillCatalogRoots } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); @@ -28,6 +30,26 @@ const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array { + it.effect("includes grok home, plugins, shared agents, and workspace roots", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect( + listGrokSkillCatalogRoots(path, "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/workspaces/demo", { + GROK_HOME: "/opt/grok-data", + HOME: "/home/dev", + }), + ).toEqual([ + path.join("/opt/grok-data", "skills"), + path.join("/opt/grok-data", "installed-plugins"), + path.join("/home/dev", ".agents", "skills"), + path.join("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/workspaces/demo", ".grok", "skills"), + path.join("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/workspaces/demo", ".agents", "skills"), + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + describe("discoverGrokSkills", () => { it.effect("maps inspect entries onto provider skills, sorted by name", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index 36391e3d2008..d835752685ed 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -15,9 +15,12 @@ * * @module provider/Drivers/GrokSkills */ +import * as NodeOS from "node:os"; + import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcess } from "effect/unstable/process"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -26,6 +29,27 @@ import { spawnAndCollect } from "../providerSnapshot.ts"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; +/** + * Directories `grok inspect` can surface skills from. Plugin skills live + * under `installed-plugins`, which a flat `skills/` scan would miss, so that + * tree is fingerprinted too. + */ +export function listGrokSkillCatalogRoots( + path: Path.Path, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const grokHome = environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"); + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + return [ + path.join(grokHome, "skills"), + path.join(grokHome, "installed-plugins"), + path.join(userHome, ".agents", "skills"), + path.join(cwd, ".grok", "skills"), + path.join(cwd, ".agents", "skills"), + ]; +} + class GrokSkillsProbeError extends Schema.TaggedError()( "GrokSkillsProbeError", { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 0d874b9ceba8..200161093fd1 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,6 +12,8 @@ * * @module provider/Drivers/OpenCodeDriver */ +import * as NodeOS from "node:os"; + import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -61,6 +63,22 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); +export function listOpenCodeSkillCatalogRoots( + path: Path.Path, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + const configHome = environment.XDG_CONFIG_HOME?.trim() || path.join(userHome, ".config"); + return [ + path.join(cwd, ".opencode", "skills"), + path.join(cwd, ".agents", "skills"), + path.join(configHome, "opencode", "skills"), + path.join(userHome, ".opencode", "skills"), + path.join(userHome, ".agents", "skills"), + ]; +} + function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -287,6 +305,8 @@ export const OpenCodeDriver: ProviderDriver }), ), ), + skillCatalogRoots: (cwd) => + Effect.succeed(listOpenCodeSkillCatalogRoots(pathService, cwd, processEnv)), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d6e7eb44f92d..caee1981d79f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1590,124 +1590,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it.effect("replaces a cached workspace skill snapshot when force-refreshing", () => - 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: [], - skills: [{ name: "global", path: "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/global/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const firstScoped = { - ...machineProvider, - checkedAt: "2026-06-10T00:01:00.000Z", - skills: [{ name: "review", path: "/workspace/review/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const secondScoped = { - ...machineProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - skills: [ - { name: "review", path: "/workspace/review/SKILL.md", enabled: true }, - { name: "deploy", path: "/workspace/deploy/SKILL.md", enabled: true }, - ], - } as const satisfies ServerProvider; - const snapshotCalls = yield* Ref.make(0); - const scopedSnapshot = yield* Ref.make(firstScoped); - const instance: ProviderInstance = { - instanceId, - driverKind: driver, - continuationIdentity: { - driverKind: driver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(machineProvider), - refresh: Effect.succeed(machineProvider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - snapshotForCwd: () => - Ref.update(snapshotCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(scopedSnapshot)), - ), - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }; - const registryChanges = yield* PubSub.unbounded(); - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Effect.succeed(requestedId === instanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - 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-skill-refresh-", - }), - ), - 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* Ref.get(snapshotCalls), 1); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, - firstScoped.skills, - ); - - yield* Ref.set(scopedSnapshot, secondScoped); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 1); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, - firstScoped.skills, - ); - - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - force: true, - }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, - secondScoped.skills, - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - it.effect("refreshes OpenCode catalogs and preserves other providers", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 3fa070330c33..064070e61518 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -37,6 +37,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -54,6 +55,13 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + fingerprintSkillCatalogRoots, + isSkillCatalogTargetForInstance, + refreshChangedSkillCatalogs, + skillCatalogTargetKey, + SKILL_CATALOG_REFRESH_INTERVAL, +} from "../skillCatalogFingerprint.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -360,6 +368,7 @@ export const ProviderRegistryLive = Layer.effect( const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); + const skillFingerprintsRef = yield* Ref.make>(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -646,6 +655,17 @@ export const ProviderRegistryLive = Layer.effect( .filter((instanceId) => previousSubs.has(instanceId)), ); if (rebuiltInstanceIds.size > 0) { + yield* Ref.update(skillFingerprintsRef, (fingerprints) => { + const next = new Map(fingerprints); + for (const instanceId of rebuiltInstanceIds) { + for (const key of next.keys()) { + if (isSkillCatalogTargetForInstance(key, instanceId)) { + next.delete(key); + } + } + } + return next; + }); const [previousProviders, providers] = yield* Ref.modify( providersRef, (previousProviders) => { @@ -726,6 +746,19 @@ export const ProviderRegistryLive = Layer.effect( } return next; }); + yield* Ref.update(skillFingerprintsRef, (fingerprints) => { + const next = new Map(fingerprints); + for (const key of fingerprints.keys()) { + const instanceId = key.split("\0")[0]; + if ( + instanceId === undefined || + !knownInstanceIds.has(ProviderInstanceId.make(instanceId)) + ) { + next.delete(key); + } + } + return next; + }); }), ); const syncLiveSourcesAndContinue = syncLiveSources.pipe( @@ -803,17 +836,47 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const rememberSkillCatalogFingerprint = Effect.fn("rememberSkillCatalogFingerprint")(function* ( + instance: ProviderInstance, + cwd: string, + ) { + if (!instance.skillCatalogRoots) return; + const roots = yield* instance.skillCatalogRoots(cwd); + const fingerprint = yield* fingerprintSkillCatalogRoots(roots).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + yield* Ref.update(skillFingerprintsRef, (fingerprints) => + new Map(fingerprints).set(skillCatalogTargetKey(instance.instanceId, cwd), fingerprint), + ); + }); + + const dropWorkspaceSnapshot = (instanceId: ProviderInstanceId, cwd: string) => + Ref.update(providersRef, (providers) => + providers.map((provider) => { + if (provider.instanceId !== instanceId) return provider; + const current = provider.workspaceSnapshots; + if (!current?.some((snapshot) => snapshot.cwd === cwd)) return provider; + const next = current.filter((snapshot) => snapshot.cwd !== cwd); + if (next.length === 0) { + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + } + return { ...provider, workspaceSnapshots: next }; + }), + ); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; - readonly force?: boolean; }) { const providers = yield* Ref.get(providersRef); const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - const hasWorkspaceSnapshot = Boolean( - provider?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd), - ); - if (!provider || !provider.enabled || (hasWorkspaceSnapshot && !input.force)) { + if ( + !provider || + !provider.enabled || + provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -836,10 +899,7 @@ export const ProviderRegistryLive = Layer.effect( return Ref.modify(providersRef, (currentProviders) => { const nextProviders = currentProviders.map((candidate) => candidate.instanceId === input.instanceId && - (input.force || - !candidate.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === input.cwd, - )) + !candidate.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, ); @@ -850,6 +910,7 @@ export const ProviderRegistryLive = Layer.effect( ? PubSub.publish(changesPubSub, nextProviders) : Effect.void, ), + Effect.tap(() => rememberSkillCatalogFingerprint(instance, input.cwd)), Effect.map(([, nextProviders]) => nextProviders), ); }), @@ -868,6 +929,87 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const refreshStaleSkillCatalogs = Effect.fn("refreshStaleSkillCatalogs")(function* () { + const providers = yield* Ref.get(providersRef); + const instances = yield* instanceRegistry.listInstances; + const instanceById = new Map( + instances.map((instance) => [instance.instanceId, instance] as const), + ); + const targets = yield* Effect.forEach( + providers.flatMap((provider) => { + const instance = instanceById.get(provider.instanceId); + const listRoots = instance?.skillCatalogRoots; + if ( + !provider.enabled || + !instance?.snapshotForCwd || + !listRoots || + !provider.workspaceSnapshots?.length + ) { + return []; + } + return provider.workspaceSnapshots.map((snapshot) => ({ + instance, + listRoots, + cwd: snapshot.cwd, + })); + }), + ({ instance, listRoots, cwd }) => + listRoots(cwd).pipe( + Effect.map((roots) => ({ + key: skillCatalogTargetKey(instance.instanceId, cwd), + roots, + refresh: Effect.gen(function* () { + const before = (yield* Ref.get(providersRef)) + .find((provider) => provider.instanceId === instance.instanceId) + ?.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); + yield* dropWorkspaceSnapshot(instance.instanceId, cwd); + const after = yield* refreshWorkspaceSnapshot({ + instanceId: instance.instanceId, + cwd, + }).pipe(Effect.catchCause(recoverRefreshFailure)); + const replaced = after + .find((provider) => provider.instanceId === instance.instanceId) + ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd); + if (before !== undefined && !replaced) { + yield* Ref.update(providersRef, (providers) => + providers.map((provider) => { + if (provider.instanceId !== instance.instanceId) return provider; + return { + ...provider, + workspaceSnapshots: [ + ...(provider.workspaceSnapshots ?? []).filter( + (snapshot) => snapshot.cwd !== cwd, + ), + before, + ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), + }; + }), + ); + } + }), + })), + ), + { concurrency: "unbounded" }, + ); + yield* refreshChangedSkillCatalogs({ + targets: targets.filter((target) => target.roots.length > 0), + fingerprints: skillFingerprintsRef, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + }); + + yield* Effect.sleep(SKILL_CATALOG_REFRESH_INTERVAL).pipe( + Effect.andThen( + refreshStaleSkillCatalogs().pipe( + Effect.repeat(Schedule.spaced(SKILL_CATALOG_REFRESH_INTERVAL)), + ), + ), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..1b2488b3ce04 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -73,6 +73,13 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + /** + * Directories (and occasional files) whose listing/mtime is a cheap stand-in + * for "this instance's skill catalog may have changed". The registry + * fingerprints these on a timer and re-runs `snapshotForCwd` only on a + * mismatch, so the `$` picker picks up new skills without a restart. + */ + readonly skillCatalogRoots?: (cwd: string) => Effect.Effect>; readonly refreshModels?: () => Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 57046ec4df3e..bd917834e972 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -51,11 +51,6 @@ export interface ProviderRegistryShape { readonly refreshWorkspaceSnapshot: (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; - /** - * Re-probe even when a snapshot for `cwd` already exists. The composer - * sends this when the skill or slash menu opens. - */ - readonly force?: boolean; }) => Effect.Effect>; /** diff --git a/apps/server/src/provider/skillCatalogFingerprint.test.ts b/apps/server/src/provider/skillCatalogFingerprint.test.ts new file mode 100644 index 000000000000..c51e637178e8 --- /dev/null +++ b/apps/server/src/provider/skillCatalogFingerprint.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import { + fingerprintSkillCatalogRoots, + refreshChangedSkillCatalogs, +} from "./skillCatalogFingerprint.ts"; + +const writeSkill = Effect.fn(function* (skillsDir: string, name: string, body = "# skill") { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, name); + yield* fileSystem.makeDirectory(skillDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDir, "SKILL.md"), body); +}); + +it.layer(NodeServices.layer)("skillCatalogFingerprint", (it) => { + it.effect("is stable for missing roots and changes when a skill folder appears", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-fingerprint-", + }); + const skillsDir = path.join(tempDir, "skills"); + const missingDir = path.join(tempDir, "absent"); + + const emptyFingerprint = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + const again = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + assert.strictEqual(again, emptyFingerprint); + assert.ok(emptyFingerprint.includes(`${missingDir}:!`)); + assert.ok(emptyFingerprint.includes(`${skillsDir}:!`)); + + yield* writeSkill(skillsDir, "review"); + const withSkill = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + assert.notStrictEqual(withSkill, emptyFingerprint); + + yield* writeSkill(skillsDir, "deploy"); + const withTwo = yield* fingerprintSkillCatalogRoots([skillsDir, missingDir]); + assert.notStrictEqual(withTwo, withSkill); + }), + ); + + it.effect("seeds on first observation and refreshes only after a catalog change", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-fingerprint-refresh-", + }); + const skillsDir = path.join(tempDir, "skills"); + yield* fileSystem.makeDirectory(skillsDir, { recursive: true }); + const refreshCalls = yield* Ref.make(0); + const fingerprints = yield* Ref.make>(new Map()); + const tick = refreshChangedSkillCatalogs({ + targets: [ + { + key: "codex\0/workspace", + roots: [skillsDir], + refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(Effect.asVoid), + }, + ], + fingerprints, + }); + + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 0); + + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 0); + + yield* writeSkill(skillsDir, "review"); + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 1); + + yield* tick; + assert.strictEqual(yield* Ref.get(refreshCalls), 1); + }), + ); +}); diff --git a/apps/server/src/provider/skillCatalogFingerprint.ts b/apps/server/src/provider/skillCatalogFingerprint.ts new file mode 100644 index 000000000000..b5037d4b730a --- /dev/null +++ b/apps/server/src/provider/skillCatalogFingerprint.ts @@ -0,0 +1,100 @@ +/** + * Skill catalog fingerprint — a cheap root signature used to decide + * whether the `$` picker needs a fresh skill probe. + * + * Workspace snapshots cache skills after the first `snapshotForCwd` and + * never look again, so a skill added while T3 is running stays invisible + * until restart. Re-running discovery on a timer is too expensive (Grok + * `inspect`, Codex `skills/list`, OpenCode server round-trips). This only + * stats the roots a driver already listed. Missing paths stay in the + * signature so creating a previously absent `skills` folder invalidates. + * `fs.watch` cannot do that: it fails on a path that does not exist yet. + * + * @module provider/skillCatalogFingerprint + */ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +export const SKILL_CATALOG_REFRESH_INTERVAL = Duration.seconds(3); + +export function skillCatalogTargetKey(instanceId: string, cwd: string): string { + return `${instanceId}\0${cwd}`; +} + +export function isSkillCatalogTargetForInstance(key: string, instanceId: string): boolean { + return key.startsWith(`${instanceId}\0`); +} + +function entryMtimeMs(mtime: Option.Option): string { + const value = Option.getOrNull(mtime); + return value === null ? "0" : String(value.getTime()); +} + +/** + * Stable signature of the skill roots a provider reads. One `stat` per + * unique path. Missing paths are `path:!` so mkdir on that root still + * invalidates. Directory mtime catches a new child folder. + */ +export const fingerprintSkillCatalogRoots = Effect.fn("fingerprintSkillCatalogRoots")(function* ( + directories: ReadonlyArray, +) { + const fileSystem = yield* FileSystem.FileSystem; + const parts: string[] = []; + + for (const directory of [...new Set(directories)].sort()) { + const info = yield* fileSystem.stat(directory).pipe(Effect.orElseSucceed(() => undefined)); + if (info === undefined) { + parts.push(`${directory}:!`); + continue; + } + parts.push(`${directory}:${info.type}:${entryMtimeMs(info.mtime)}:${String(info.size)}`); + } + + return parts.join("|"); +}); + +export type SkillCatalogRefreshTarget = { + readonly key: string; + readonly roots: ReadonlyArray; + readonly refresh: Effect.Effect; +}; + +/** + * Compare each target's current fingerprint to the last one we stored. + * The first observation only seeds the map so opening a thread does not + * immediately re-run discovery. A later mismatch is the invalidation + * signal; `refresh` then drops the cached snapshot and re-probes. + */ +export const refreshChangedSkillCatalogs = Effect.fn("refreshChangedSkillCatalogs")( + function* (input: { + readonly targets: ReadonlyArray; + readonly fingerprints: Ref.Ref>; + }) { + const observed = yield* Effect.forEach( + input.targets, + (target) => + fingerprintSkillCatalogRoots(target.roots).pipe( + Effect.map((fingerprint) => [target, fingerprint] as const), + ), + { concurrency: "unbounded" }, + ); + + for (const [target, fingerprint] of observed) { + const previous = (yield* Ref.get(input.fingerprints)).get(target.key); + if (previous === undefined) { + yield* Ref.update(input.fingerprints, (fingerprints) => + new Map(fingerprints).set(target.key, fingerprint), + ); + continue; + } + if (previous === fingerprint) continue; + yield* target.refresh; + yield* Ref.update(input.fingerprints, (fingerprints) => + new Map(fingerprints).set(target.key, fingerprint), + ); + } + }, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8194bb2aef0a..d118bd3d012c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2330,7 +2330,6 @@ const makeWsRpcLayer = ( ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, cwd: input.cwd, - ...(input.force === true ? { force: true } : {}), }) : input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b7ca751e3437..255f61a0c752 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -48,10 +48,7 @@ import { pastedTextDisposition, wouldTextPasteExceedLimit, } from "@t3tools/client-runtime/text-paste"; -import { - composerTriggerRefreshesSkillCatalog, - serializeComposerFileLink, -} from "@t3tools/shared/composerTrigger"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -2216,21 +2213,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Derived: composer trigger / menu // ------------------------------------------------------------------ const composerTriggerKind = composerTrigger?.kind ?? null; - const skillCatalogRefreshKeyRef = useRef(null); - useEffect(() => { - if (!composerTriggerRefreshesSkillCatalog(composerTriggerKind)) { - skillCatalogRefreshKeyRef.current = null; - return; - } - if (!gitCwd || !selectedProviderEntry) return; - const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; - if (skillCatalogRefreshKeyRef.current === key) return; - skillCatalogRefreshKeyRef.current = key; - void refreshProviders({ - environmentId, - input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd, force: true }, - }); - }, [composerTriggerKind, environmentId, gitCwd, refreshProviders, selectedProviderEntry]); const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const pullRequestTriggerQuery = composerTrigger?.kind === "pull-request" ? composerTrigger.query : ""; diff --git a/docs/user/composer.md b/docs/user/composer.md index fb51bd50754c..b7aeb049233e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -148,6 +148,8 @@ provider. On mobile, both are also available before starting a thread on The slash menu also includes skills unless you turn off **Settings → General → Show skills in slash menu**. Only skills enabled for the provider are listed. +Skills you add to a provider's skill directories appear in the picker without +restarting T3 Code. Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7464ec57968f..18127076ff19 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1069,7 +1069,6 @@ export function createServerEnvironmentAtoms( environmentId, input.instanceId ?? null, input.cwd ?? null, - input.force ?? false, input.refreshModels ?? false, ]), }, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5e6c179d7e49..41af6d9f1e9e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -476,11 +476,6 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), - /** - * Re-probe a cwd that already has a workspace snapshot. The composer - * sends this when the skill or slash menu opens. - */ - force: Schema.optional(Schema.Boolean), /** Explicit user request. Background status refreshes must not open agent sessions. */ refreshModels: Schema.optional(Schema.Boolean), }), diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index ffeaccc2578f..06b7038dad2b 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - composerTriggerRefreshesSkillCatalog, - detectComposerTrigger, - serializeComposerFileLink, -} from "./composerTrigger.ts"; +import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; describe("detectComposerTrigger", () => { it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( @@ -21,20 +17,6 @@ describe("detectComposerTrigger", () => { ); }); -describe("composerTriggerRefreshesSkillCatalog", () => { - it("re-probes when the skill or slash menu is open", () => { - expect(composerTriggerRefreshesSkillCatalog("skill")).toBe(true); - expect(composerTriggerRefreshesSkillCatalog("slash-command")).toBe(true); - }); - - it("leaves other composer triggers on the cached catalog", () => { - expect(composerTriggerRefreshesSkillCatalog("path")).toBe(false); - expect(composerTriggerRefreshesSkillCatalog("pull-request")).toBe(false); - expect(composerTriggerRefreshesSkillCatalog("slash-model")).toBe(false); - expect(composerTriggerRefreshesSkillCatalog(null)).toBe(false); - }); -}); - describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { expect(serializeComposerFileLink("path/to/package.json")).toBe( diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index e5ebf3ffa83c..6153978edb92 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -13,13 +13,6 @@ export interface ComposerTrigger { rangeEnd: number; } -/** `$` and `/` both list skills, so opening either menu re-probes the catalog. */ -export function composerTriggerRefreshesSkillCatalog( - kind: ComposerTriggerKind | null | undefined, -): boolean { - return kind === "skill" || kind === "slash-command"; -} - function composerFileLinkBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;