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.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..064070e61518 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,6 +836,36 @@ 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; @@ -812,7 +875,7 @@ export const ProviderRegistryLive = Layer.effect( if ( !provider || !provider.enabled || - provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) ) { return providers; } @@ -836,7 +899,7 @@ 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) + !candidate.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, ); @@ -847,6 +910,7 @@ export const ProviderRegistryLive = Layer.effect( ? PubSub.publish(changesPubSub, nextProviders) : Effect.void, ), + Effect.tap(() => rememberSkillCatalogFingerprint(instance, input.cwd)), Effect.map(([, nextProviders]) => nextProviders), ); }), @@ -865,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/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/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.