diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1141ac5856fc..ccbe1c80e868 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 { + antigravitySkillCatalogRoots, + discoverAntigravitySkills, + resolveAntigravityUserHome, +} from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -471,6 +475,13 @@ export const AntigravityDriver: ProviderDriver + Effect.succeed( + antigravitySkillCatalogRoots(path, { cwd: workspaceCwd, userHome }).map((root) => ({ + path: root.directory, + recursive: true, + })), + ), 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..f9d80212279d 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -48,6 +48,23 @@ export function antigravityUserSkillDirectories( ]; } +export function antigravitySkillCatalogRoots( + path: Path.Path, + input: { readonly cwd: string; readonly userHome: string }, +): ReadonlyArray<{ readonly directory: string; readonly scope: "user" | "project" }> { + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + path, + path.join(input.userHome, ".gemini"), + ); + return [ + { directory: configSkills, scope: "user" }, + { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, + { directory: cliSkills, scope: "user" }, + { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, + { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, + ]; +} + const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; @@ -167,17 +184,7 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const [configSkills, cliSkills] = antigravityUserSkillDirectories( - path, - path.join(input.userHome, ".gemini"), - ); - const roots = [ - { directory: configSkills, scope: "user" }, - { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, - { directory: cliSkills, scope: "user" }, - { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, - { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, - ]; + const roots = antigravitySkillCatalogRoots(path, input); const budget: ScanBudget = { remainingBytes: MAX_SCAN_BYTES, remainingEntries: MAX_SCAN_ENTRIES, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b87a89dcf528..a724c593bb38 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 { claudeSkillCatalogWatchPaths, discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -262,6 +262,11 @@ export const ClaudeDriver: ProviderDriver = { enabled, snapshot, snapshotForCwd, + skillCatalogWatchPaths: (workspaceCwd) => + claudeSkillCatalogWatchPaths(effectiveConfig, workspaceCwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + 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..a61f9d00c382 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 { + claudeSkillCatalogWatchPaths, + discoverClaudeSkills, + skillOverrideSettingsPaths, +} from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -20,6 +24,47 @@ const writeSkill = Effect.fn(function* ( }); it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("resolves watch paths from the same instance config and environment", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-watch-paths-" }); + const workspace = path.join(tempDir, "workspace"); + const configuredHome = path.join(tempDir, "configured-home"); + const inheritedHome = path.join(tempDir, "inherited-home"); + + const configured = yield* claudeSkillCatalogWatchPaths( + { homePath: configuredHome }, + workspace, + { ...process.env, CLAUDE_CONFIG_DIR: inheritedHome }, + ); + assert.strictEqual( + configured.some( + (watchPath) => + watchPath.path === path.join(configuredHome, "skills") && watchPath.recursive, + ), + true, + ); + assert.strictEqual( + configured.some((watchPath) => watchPath.path.startsWith(inheritedHome)), + false, + ); + + const inherited = yield* claudeSkillCatalogWatchPaths({ homePath: "" }, workspace, { + ...process.env, + CLAUDE_CONFIG_DIR: "relative-home", + }); + assert.strictEqual( + inherited.some( + (watchPath) => + watchPath.path === path.join(workspace, "relative-home", "skills") && + watchPath.recursive, + ), + true, + ); + }), + ); + it.effect("discovers user and project skills with frontmatter metadata", () => 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..47d8eed6ad99 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -295,6 +295,33 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(NodeOS.homedir(), ".claude"); }); +export const claudeSkillCatalogWatchPaths = Effect.fn("claudeSkillCatalogWatchPaths")(function* ( + config: Pick, + cwd: string, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return< + ReadonlyArray<{ readonly path: string; readonly recursive: boolean }>, + never, + FileSystem.FileSystem | Path.Path +> { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); + const repositoryRoot = yield* findRepositoryRoot(cwd); + return [ + { path: path.join(configDirPath, "skills"), recursive: true }, + { path: path.join(cwd, ".claude", "skills"), recursive: true }, + ...skillOverrideSettingsPaths( + path, + configDirPath, + cwd, + platform, + environment, + repositoryRoot, + ).map((settingsPath) => ({ path: settingsPath, recursive: false })), + ]; +}); + /** * 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/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 70af46ff6867..a5d89e8cd227 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 { + cursorSkillCatalogRoots, + discoverCursorSkills, + probeCursorSkills, +} from "./CursorSkills.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -234,6 +238,13 @@ export const CursorDriver: ProviderDriver = { ), Effect.flatMap((skills) => snapshotForCwd(cwd, skills)), ), + skillCatalogWatchPaths: (workspaceCwd) => + Effect.succeed( + cursorSkillCatalogRoots(path, workspaceCwd, processEnv).map((root) => ({ + path: root.directory, + recursive: true, + })), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index c8cd561dfea0..4ace3b1febb0 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -221,14 +221,7 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( environment: NodeJS.ProcessEnv = process.env, ) { const path = yield* Path.Path; - const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); - const rootsBelow = (base: string, scope: "user" | "project") => [ - { directory: path.join(base, ".cursor", "skills"), scope }, - { directory: path.join(base, ".agents", "skills"), scope }, - { directory: path.join(base, ".codex", "skills"), scope }, - { directory: path.join(base, ".claude", "skills"), scope }, - ]; - const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; + const roots = cursorSkillCatalogRoots(path, cwd, environment); const skillsByName = new Map(); const budget: CursorSkillScanBudget = { @@ -254,6 +247,21 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( }; }); +export function cursorSkillCatalogRoots( + path: Path.Path, + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +): 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 }, + { directory: path.join(base, ".agents", "skills"), scope }, + { directory: path.join(base, ".codex", "skills"), scope }, + { directory: path.join(base, ".claude", "skills"), scope }, + ]; + return [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; +} + export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( cwd?: string, environment: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..06d17e565be8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -3,9 +3,11 @@ import { describe, it, assert } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; @@ -604,6 +606,47 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("distinguishes omitted, cleared, and disabled workspace catalogs", () => { + const machineProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const previousProvider = upsertProviderWorkspaceSnapshot( + machineProvider, + "/project", + machineProvider, + ); + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, machineProvider).workspaceSnapshots, + previousProvider.workspaceSnapshots, + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, { + ...machineProvider, + workspaceSnapshots: [], + }).workspaceSnapshots, + [], + ); + assert.strictEqual( + mergeProviderSnapshot(previousProvider, { + ...machineProvider, + enabled: false, + status: "disabled", + }).workspaceSnapshots, + undefined, + ); + }); + it("preserves previously discovered provider models when a refresh returns none", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -1428,10 +1471,200 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => + it.effect( + "deduplicates cwd probes and keeps workspace snapshots when an instance rebuilds", + () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const machineProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "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/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const scopedProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "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/workspace/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const pendingScopedProvider = { + ...scopedProvider, + status: "error", + installed: false, + slashCommands: [], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const returnPendingSnapshot = yield* Ref.make(true); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const makeInstance = ( + provider: ServerProvider, + snapshotForCwd: NonNullable, + ): ProviderInstance => ({ + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const firstInstance = makeInstance(machineProvider, () => + Effect.gen(function* () { + yield* Ref.update(snapshotCalls, (count) => count + 1); + if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + return scopedProvider; + }), + ); + const rebuiltProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + status: "warning", + installed: false, + auth: { status: "unknown" }, + } satisfies ServerProvider; + const rebuiltScopedProvider = { + ...scopedProvider, + checkedAt: "2026-06-10T00:03:00.000Z", + skills: [{ name: "rebuilt", path: "/workspace/rebuilt/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const rebuiltInstance = makeInstance(rebuiltProvider, () => + Ref.update(snapshotCalls, (count) => count + 1).pipe( + Effect.as(rebuiltScopedProvider), + ), + ); + const registryChanges = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([firstInstance]); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instancesRef), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(registryChanges), + subscribeChanges: PubSub.subscribe(registryChanges), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-workspace-snapshot-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); + yield* Ref.set(returnPendingSnapshot, false); + const workspaceUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const firstRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const duplicateRefresh = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(firstRefresh); + yield* Fiber.join(duplicateRefresh); + const published = yield* Fiber.join(workspaceUpdate); + assert.strictEqual(published._tag, "Some"); + const providers = yield* registry.getProviders; + assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.[0]?.skills, + scopedProvider.skills, + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + + yield* Ref.set(instancesRef, [rebuiltInstance]); + yield* PubSub.publish(registryChanges, undefined); + let rebuilt = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && + (rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt || + rebuilt[0]?.workspaceSnapshots?.[0]?.checkedAt !== + rebuiltScopedProvider.checkedAt); + attempt += 1 + ) { + yield* Effect.yieldNow; + rebuilt = yield* registry.getProviders; + } + assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); + assert.deepStrictEqual( + rebuilt[0]?.workspaceSnapshots?.[0]?.skills, + rebuiltScopedProvider.skills, + ); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("shows skill files in the workspace snapshot when they change", () => Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-catalog-watch-", + }); + const skillsDir = path.join(tempDir, ".claude", "skills"); + const reviewSkill = { + name: "review", + path: path.join(skillsDir, "review", "SKILL.md"), + enabled: true, + }; + + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); const machineProvider = { instanceId, driver, @@ -1442,34 +1675,33 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te checkedAt: "2026-06-10T00:00:00.000Z", version: "1.0.0", models: [], - slashCommands: [{ name: "global" }], - skills: [{ name: "global", path: "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/global/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const scopedProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:01:00.000Z", - slashCommands: [{ name: "project" }], - skills: [{ name: "project", path: "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/workspace/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const pendingScopedProvider = { - ...scopedProvider, - status: "error", - installed: false, slashCommands: [], + skills: [], } as const satisfies ServerProvider; - const snapshotCalls = yield* Ref.make(0); - const returnPendingSnapshot = yield* Ref.make(true); - const probeStarted = yield* Deferred.make(); - const releaseProbe = yield* Deferred.make(); - const makeInstance = ( - provider: ServerProvider, - snapshotForCwd: NonNullable, - ): ProviderInstance => ({ + const listWorkspaceSkills = fileSystem.readDirectory(skillsDir).pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + Effect.flatMap((entries) => + Effect.forEach( + [...entries].sort(), + (name) => + fileSystem.readFileString(path.join(skillsDir, name, "SKILL.md")).pipe( + Effect.orElseSucceed(() => ""), + Effect.map((contents) => ({ + name, + path: path.join(skillsDir, name, "SKILL.md"), + enabled: !contents.includes("off"), + })), + ), + { concurrency: "unbounded" }, + ), + ), + ); + const instance = { instanceId, driverKind: driver, continuationIdentity: { driverKind: driver, - continuationKey: "codex:instance:codex", + continuationKey: "claudeAgent:instance:claudeAgent", }, displayName: undefined, enabled: true, @@ -1481,49 +1713,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te packageName: null, }), ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), + getSnapshot: Effect.succeed(machineProvider), + refresh: Effect.succeed(machineProvider), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, - snapshotForCwd, + snapshotForCwd: () => + listWorkspaceSkills.pipe(Effect.map((skills) => ({ ...machineProvider, skills }))), + skillCatalogWatchPaths: () => Effect.succeed([{ path: skillsDir, recursive: true }]), adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], - }); - const firstInstance = makeInstance(machineProvider, () => - Effect.gen(function* () { - yield* Ref.update(snapshotCalls, (count) => count + 1); - if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; - yield* Deferred.succeed(probeStarted, undefined); - yield* Deferred.await(releaseProbe); - return scopedProvider; - }), - ); - const rebuiltProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - status: "warning", - installed: false, - auth: { status: "unknown" }, - } satisfies ServerProvider; - const rebuiltInstance = makeInstance(rebuiltProvider, () => - Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), - ); - const registryChanges = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([firstInstance]); + } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (requestedId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === requestedId), - ), - ), - listInstances: Ref.get(instancesRef), + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(registryChanges), - subscribeChanges: PubSub.subscribe(registryChanges), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), }, ); const scope = yield* Scope.make(); @@ -1533,7 +1742,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-workspace-snapshot-", + prefix: "t3-provider-registry-skill-catalog-", }), ), Layer.provideMerge(NodeServices.layer), @@ -1542,52 +1751,46 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); - yield* Ref.set(returnPendingSnapshot, false); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: tempDir }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, + [], + ); + const workspaceUpdate = yield* registry.streamChanges.pipe( Stream.runHead, Effect.forkChild, ); yield* Effect.yieldNow; - const firstRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Deferred.await(probeStarted); - const duplicateRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - yield* Deferred.succeed(releaseProbe, undefined); - yield* Fiber.join(firstRefresh); - yield* Fiber.join(duplicateRefresh); + yield* fileSystem.makeDirectory(path.join(skillsDir, "review"), { recursive: true }); + yield* fileSystem.writeFileString(reviewSkill.path, "# review"); const published = yield* Fiber.join(workspaceUpdate); - assert.strictEqual(published._tag, "Some"); - const providers = yield* registry.getProviders; - assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.strictEqual(Option.isSome(published), true); + if (!Option.isSome(published)) return; + assert.deepStrictEqual(published.value[0]?.workspaceSnapshots?.[0]?.skills, [ + reviewSkill, + ]); assert.deepStrictEqual( - providers[0]?.workspaceSnapshots?.[0]?.skills, - scopedProvider.skills, + (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.skills, + [reviewSkill], ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - yield* Ref.set(instancesRef, [rebuiltInstance]); - yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } - assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); - assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); - }).pipe(Effect.provide(runtimeServices)); - }), + const skillFileUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* fileSystem.writeFileString(reviewSkill.path, "# review\noff"); + const republished = yield* Fiber.join(skillFileUpdate); + assert.strictEqual(Option.isSome(republished), true); + if (!Option.isSome(republished)) return; + assert.deepStrictEqual(republished.value[0]?.workspaceSnapshots?.[0]?.skills, [ + { ...reviewSkill, enabled: false }, + ]); + }).pipe(Effect.provide(runtimeServices), TestClock.withLive); + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("refreshes OpenCode catalogs and preserves other providers", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..41a562f298d0 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -30,13 +30,16 @@ import { type ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; 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 Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -54,6 +57,13 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + skillCatalogPathsFromSkills, + skillCatalogWatchEventAffectsTarget, + skillCatalogWatchTargets, +} from "../skillCatalogWatch.ts"; + +const skillCatalogTargetKey = (instanceId: string, cwd: string) => `${instanceId}\0${cwd}`; const loadProviders = ( providerSources: ReadonlyArray, @@ -211,7 +221,7 @@ export const mergeProviderSnapshot = ( models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), ...(nextProvider.workspaceSnapshots !== undefined ? { workspaceSnapshots: nextProvider.workspaceSnapshots } - : previousProvider.workspaceSnapshots !== undefined + : nextProvider.enabled && previousProvider.workspaceSnapshots !== undefined ? { workspaceSnapshots: previousProvider.workspaceSnapshots } : {}), ...(shouldRetainMissingOpenCodeMetadata(nextProvider) @@ -281,6 +291,7 @@ export const ProviderRegistryLive = Layer.effect( const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const registryScope = yield* Scope.Scope; // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for // coalesced updates across every instance. @@ -640,28 +651,6 @@ export const ProviderRegistryLive = Layer.effect( newlyAdded.push([instanceId, instance] as const); } - const rebuiltInstanceIds = new Set( - newlyAdded - .map(([instanceId]) => instanceId) - .filter((instanceId) => previousSubs.has(instanceId)), - ); - if (rebuiltInstanceIds.size > 0) { - const [previousProviders, providers] = yield* Ref.modify( - providersRef, - (previousProviders) => { - const providers = previousProviders.map((provider) => { - if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; - const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; - return machineSnapshot; - }); - return [[previousProviders, providers] as const, providers]; - }, - ); - if (haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); - } - } - // Fork long-lived subscriptions to each new/rebuilt instance's // change stream before reading its current snapshot. If the // driver's own initial probe finishes during this sync, either @@ -803,17 +792,13 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); - const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + const replaceWorkspaceSnapshot = Effect.fn("replaceWorkspaceSnapshot")(function* (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; }) { const providers = yield* Ref.get(providersRef); const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - if ( - !provider || - !provider.enabled || - provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) - ) { + if (!provider?.enabled) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -835,8 +820,7 @@ export const ProviderRegistryLive = Layer.effect( if (currentInstance !== instance) return Ref.get(providersRef); return Ref.modify(providersRef, (currentProviders) => { const nextProviders = currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + candidate.instanceId === input.instanceId ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, ); @@ -865,6 +849,224 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const providers = yield* Ref.get(providersRef); + const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); + if (provider?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd)) { + return providers; + } + return yield* replaceWorkspaceSnapshot(input); + }); + + const listSkillCatalogTargets = Effect.fn("listSkillCatalogTargets")(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); + if ( + !provider.enabled || + !instance?.snapshotForCwd || + !provider.workspaceSnapshots?.length + ) { + return []; + } + return provider.workspaceSnapshots.map((snapshot) => ({ instance, snapshot })); + }), + ({ instance, snapshot }) => + Effect.gen(function* () { + const configuredPaths = instance.skillCatalogWatchPaths + ? yield* instance.skillCatalogWatchPaths(snapshot.cwd) + : []; + const observedPaths = skillCatalogPathsFromSkills( + path, + snapshot.skills.map((skill) => skill.path), + ); + const roots = [ + ...new Map( + [...configuredPaths, ...observedPaths].map((watchPath) => [ + `${path.resolve(watchPath.path)}\0${watchPath.recursive}`, + { path: path.resolve(watchPath.path), recursive: watchPath.recursive }, + ]), + ).values(), + ]; + return { + key: skillCatalogTargetKey(instance.instanceId, snapshot.cwd), + roots, + instance, + instanceId: instance.instanceId, + cwd: snapshot.cwd, + }; + }), + { concurrency: "unbounded" }, + ); + return targets.filter((target) => target.roots.length > 0); + }); + + const watchTargetsSignature = ( + watchTargets: ReadonlyArray<{ + readonly path: string; + readonly recursive: boolean; + readonly expectedPaths: ReadonlyArray | undefined; + }>, + ) => + watchTargets + .map( + (watchTarget) => + `${watchTarget.path}\0${watchTarget.recursive}\0${watchTarget.expectedPaths?.join("\0") ?? "*"}`, + ) + .join("\n"); + const resolveSkillCatalogWatchTargets = ( + roots: ReadonlyArray<{ readonly path: string; readonly recursive: boolean }>, + ) => + skillCatalogWatchTargets(roots).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const watchSkillCatalogTarget = Effect.fn("watchSkillCatalogTarget")(function* (target: { + readonly roots: ReadonlyArray<{ readonly path: string; readonly recursive: boolean }>; + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + return yield* Effect.forever( + Effect.gen(function* () { + const watchTargets = yield* resolveSkillCatalogWatchTargets(target.roots); + if (watchTargets.length === 0) { + return yield* Effect.never; + } + const signature = watchTargetsSignature(watchTargets); + yield* Stream.mergeAll( + watchTargets.map((watchTarget) => + fileSystem + .watch(watchTarget.path, { recursive: watchTarget.recursive }) + .pipe( + Stream.filter((event) => + skillCatalogWatchEventAffectsTarget(path, watchTarget, event), + ), + ), + ), + { concurrency: "unbounded" }, + ).pipe( + Stream.debounce(Duration.millis(100)), + Stream.tap(() => + replaceWorkspaceSnapshot({ + instanceId: target.instanceId, + cwd: target.cwd, + }).pipe(Effect.ignoreCause({ log: true })), + ), + Stream.takeUntilEffect(() => + resolveSkillCatalogWatchTargets(target.roots).pipe( + Effect.map((next) => watchTargetsSignature(next) !== signature), + ), + ), + Stream.runDrain, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("skill catalog watch failed; retrying", { + instanceId: target.instanceId, + cwd: target.cwd, + cause: Cause.pretty(cause), + }).pipe(Effect.andThen(Effect.sleep(Duration.millis(250)))), + ), + ), + ); + }); + + const catalogWatchersRef = yield* Ref.make< + ReadonlyMap< + string, + { + readonly rootsSignature: string; + readonly instance: ProviderInstance; + readonly fiber: Fiber.Fiber; + } + > + >(new Map()); + const skillCatalogRootsSignature = ( + roots: ReadonlyArray<{ readonly path: string; readonly recursive: boolean }>, + ) => + roots + .map((root) => `${root.path}\0${root.recursive}`) + .sort() + .join("\n"); + + const catalogReconcileSemaphore = yield* Semaphore.make(1); + const reconcileSkillCatalogWatchesUnserialized = Effect.fn("reconcileSkillCatalogWatches")( + function* () { + const targets = yield* listSkillCatalogTargets(); + const desired = new Map(targets.map((target) => [target.key, target] as const)); + const running = yield* Ref.get(catalogWatchersRef); + + for (const [key, watcher] of running) { + const next = desired.get(key); + if ( + next !== undefined && + watcher.instance === next.instance && + watcher.rootsSignature === skillCatalogRootsSignature(next.roots) + ) { + continue; + } + yield* Fiber.interrupt(watcher.fiber); + } + + const nextWatchers = new Map< + string, + { + readonly rootsSignature: string; + readonly instance: ProviderInstance; + readonly fiber: Fiber.Fiber; + } + >(); + for (const [key, target] of desired) { + const existing = running.get(key); + if ( + existing !== undefined && + existing.instance === target.instance && + existing.rootsSignature === skillCatalogRootsSignature(target.roots) + ) { + nextWatchers.set(key, existing); + continue; + } + if (existing !== undefined && existing.instance !== target.instance) { + yield* replaceWorkspaceSnapshot({ + instanceId: target.instanceId, + cwd: target.cwd, + }).pipe(Effect.ignoreCause({ log: true })); + } + const fiber = yield* watchSkillCatalogTarget(target).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(registryScope), + ); + nextWatchers.set(key, { + rootsSignature: skillCatalogRootsSignature(target.roots), + instance: target.instance, + fiber, + }); + } + yield* Ref.set(catalogWatchersRef, nextWatchers); + }, + ); + const reconcileSkillCatalogWatches = () => + catalogReconcileSemaphore.withPermits(1)(reconcileSkillCatalogWatchesUnserialized()); + + const catalogChanges = yield* PubSub.subscribe(changesPubSub); + yield* Stream.fromSubscription(catalogChanges).pipe( + Stream.runForEach(() => reconcileSkillCatalogWatches()), + Effect.ignoreCause({ log: true }), + Effect.forkIn(registryScope), + ); + yield* reconcileSkillCatalogWatches(); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => @@ -872,7 +1074,10 @@ export const ProviderRegistryLive = Layer.effect( refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refreshWorkspaceSnapshot: (input) => - refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshWorkspaceSnapshot(input).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.tap(() => reconcileSkillCatalogWatches()), + ), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..899afdae5d7e 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -73,6 +73,14 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + /** + * Files and directories whose changes invalidate `snapshotForCwd`. + * Drivers own these paths so instance-specific configuration and environment + * stay identical to discovery. + */ + readonly skillCatalogWatchPaths?: ( + cwd: string, + ) => Effect.Effect, never>; readonly refreshModels?: () => Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then diff --git a/apps/server/src/provider/skill-catalog-watch.test.ts b/apps/server/src/provider/skill-catalog-watch.test.ts new file mode 100644 index 000000000000..5d3cb4ab6807 --- /dev/null +++ b/apps/server/src/provider/skill-catalog-watch.test.ts @@ -0,0 +1,66 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + skillCatalogWatchEventAffectsTarget, + skillCatalogWatchTargets, +} from "./skillCatalogWatch.ts"; + +describe("skillCatalogWatchTargets", () => { + it.effect("retargets from the nearest existing ancestor to the requested directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-skill-watch-target-", + }); + const claudeDirectory = path.join(cwd, ".claude"); + const skillsDirectory = path.join(claudeDirectory, "skills"); + const watchPaths = [{ path: skillsDirectory, recursive: true }] as const; + + const fromWorkspace = yield* skillCatalogWatchTargets(watchPaths); + assert.deepStrictEqual(fromWorkspace, [ + { + path: cwd, + recursive: false, + expectedPaths: [claudeDirectory], + }, + ]); + assert.strictEqual( + skillCatalogWatchEventAffectsTarget(path, fromWorkspace[0]!, { + _tag: "Create", + path: ".claude", + }), + true, + ); + assert.strictEqual( + skillCatalogWatchEventAffectsTarget(path, fromWorkspace[0]!, { + _tag: "Update", + path: "src", + }), + false, + ); + + yield* fileSystem.makeDirectory(claudeDirectory); + assert.deepStrictEqual(yield* skillCatalogWatchTargets(watchPaths), [ + { + path: claudeDirectory, + recursive: false, + expectedPaths: [skillsDirectory], + }, + ]); + + yield* fileSystem.makeDirectory(skillsDirectory); + assert.deepStrictEqual(yield* skillCatalogWatchTargets(watchPaths), [ + { + path: skillsDirectory, + recursive: true, + expectedPaths: undefined, + }, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/skillCatalogWatch.ts b/apps/server/src/provider/skillCatalogWatch.ts new file mode 100644 index 000000000000..c236a3dc209f --- /dev/null +++ b/apps/server/src/provider/skillCatalogWatch.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +export function skillCatalogPathsFromSkills( + path: Path.Path, + skillPaths: ReadonlyArray, +): ReadonlyArray<{ readonly path: string; readonly recursive: boolean }> { + return [ + ...new Set( + skillPaths.map((skillPath) => { + const skillDirectory = path.resolve(path.dirname(skillPath)); + const catalogDirectory = path.dirname(skillDirectory); + return path.dirname(catalogDirectory) === catalogDirectory + ? skillDirectory + : catalogDirectory; + }), + ), + ].map((directory) => ({ path: directory, recursive: true })); +} + +export const skillCatalogWatchTargets = Effect.fn("skillCatalogWatchTargets")(function* ( + watchPaths: ReadonlyArray<{ readonly path: string; readonly recursive: boolean }>, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const roots = [ + ...new Map( + watchPaths.map((watchPath) => [ + `${path.resolve(watchPath.path)}\0${watchPath.recursive}`, + { path: path.resolve(watchPath.path), recursive: watchPath.recursive }, + ]), + ).values(), + ]; + const targets = new Map< + string, + { + readonly path: string; + readonly recursive: boolean; + readonly expectedPaths: ReadonlySet | undefined; + } + >(); + const addTarget = (watchPath: string, recursive: boolean, expectedPath?: string) => { + const key = `${watchPath}\0${recursive}`; + const existing = targets.get(key); + if (existing?.expectedPaths === undefined && targets.has(key)) return; + if (expectedPath === undefined) { + targets.set(key, { path: watchPath, recursive, expectedPaths: undefined }); + return; + } + targets.set(key, { + path: watchPath, + recursive, + expectedPaths: new Set([...(existing?.expectedPaths ?? []), expectedPath]), + }); + }; + + for (const root of roots) { + const info = yield* fileSystem.stat(root.path).pipe(Effect.orElseSucceed(() => undefined)); + if (info !== undefined) { + addTarget(root.path, root.recursive && info.type === "Directory"); + continue; + } + + let expectedPath = root.path; + while (true) { + const parent = path.dirname(expectedPath); + if (parent === expectedPath || path.dirname(parent) === parent) break; + const parentInfo = yield* fileSystem.stat(parent).pipe(Effect.orElseSucceed(() => undefined)); + if (parentInfo?.type === "Directory") { + addTarget(parent, false, expectedPath); + break; + } + expectedPath = parent; + } + } + + return [...targets.values()].map((target) => ({ + ...target, + expectedPaths: + target.expectedPaths === undefined ? undefined : [...target.expectedPaths].sort(), + })); +}); + +export function skillCatalogWatchEventAffectsTarget( + path: Path.Path, + target: { + readonly path: string; + readonly expectedPaths: ReadonlyArray | undefined; + }, + event: FileSystem.WatchEvent, +): boolean { + if (target.expectedPaths === undefined) return true; + const reportedPath = path.resolve(target.path, event.path); + return target.expectedPaths.some( + (expectedPath) => + reportedPath === expectedPath || reportedPath.startsWith(`${expectedPath}${path.sep}`), + ); +} 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.