diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index bf3c9318877bb..4633ffba97354 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -7,10 +7,12 @@ import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { + type ClaudeHookSlashCommandsByCwd, buildClaudeCapabilitiesProbeQueryOptions, probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, @@ -47,6 +49,53 @@ it.effect("reports a deleted worktree's scan as finished, not pending", () => }).pipe(Effect.provide(NodeServices.layer)), ); +// regression: #17418 a mod registers its commands in a SessionStart hook, which hookless scans skip +it.effect("lists hook-registered commands from one hook-running probe per folder", () => + Effect.gen(function* () { + const hookProbeCwds: Array = []; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + const hookless = + (options?.settings as { readonly disableAllHooks?: boolean } | undefined) + ?.disableAllHooks === true; + if (!hookless) hookProbeCwds.push(options?.cwd); + return { + initializationResult: async () => ({ + commands: [ + { name: "review", description: "Review changes", argumentHint: "" }, + ...(hookless ? [] : [{ name: "handoffs", description: "Handoffs", argumentHint: "" }]), + ], + }), + } as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + const settings = yield* decodeClaudeSettings({ binaryPath: "claude" }); + const environment = { + PATH: (yield* HostProcess.Environment).PATH, + CLAUDE_CONFIG_DIR: yield* fileSystem.makeTempDirectoryScoped(), + }; + const byCwd = yield* Ref.make(new Map()); + const scan = (discover: boolean, scanCwd = cwd) => + probeClaudeWorkspaceSnapshot( + settings, + { skills: [], slashCommands: [] } as unknown as ServerProvider, + scanCwd, + environment, + { byCwd, discover }, + ).pipe(Effect.map((snapshot) => snapshot.slashCommands.map((command) => command.name))); + + // A caller that may not run hooks neither runs them nor remembers their absence. + assert.deepEqual(yield* scan(false), ["compact", "review"]); + assert.deepEqual(hookProbeCwds, []); + assert.deepEqual(yield* scan(true), ["compact", "review", "handoffs"]); + // Later scans, a read-only caller's included, reuse the answer without running hooks again. + assert.deepEqual(yield* scan(true), ["compact", "review", "handoffs"]); + assert.deepEqual(yield* scan(false), ["compact", "review", "handoffs"]); + assert.deepEqual(hookProbeCwds, [cwd]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () => Effect.gen(function* () { let abortSignal: AbortSignal | undefined; diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18885f0417ebb..85b92c5b817b5 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -233,14 +233,16 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { readonly environment: NodeJS.ProcessEnv; readonly cwd: string | undefined; readonly managedMcpConfig: boolean; + readonly runHooks?: boolean; }): ClaudeQueryOptions { return { persistSession: false, pathToClaudeCodeExecutable: input.executablePath, abortController: input.abortController, settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], - // Keep settings for slash-command discovery, but disable hooks so health checks do not run SessionStart repeatedly. - settings: { disableAllHooks: true }, + // Keep settings for slash-command discovery, but disable hooks so health checks do not run + // SessionStart repeatedly. Only the once-per-folder hook command discovery runs them. + ...(input.runHooks ? {} : { settings: { disableAllHooks: true } }), allowedTools: [], // Exclude filesystem MCP servers; omit strict mode when managed-mcp.json already excludes them and forbids the flag. mcpServers: {}, @@ -445,6 +447,7 @@ const probeClaudeCapabilities = ( environment?: NodeJS.ProcessEnv, cwd?: string, includeStatus = true, + runHooks = false, ) => { const abort = new AbortController(); const abortProbe = Effect.sync(() => { @@ -477,8 +480,10 @@ const probeClaudeCapabilities = ( environment: claudeEnvironment, cwd, managedMcpConfig, + runHooks, }), }); + // Claude Code answers initialize after SessionStart hooks finish, so it lists their commands. const init = await q.initializationResult(); return { q, init, graceMs }; }, @@ -583,28 +588,69 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); +/** + * Commands that only SessionStart hooks register, such as a Claude Code mod's. Scans stay hookless + * so telemetry hooks do not log each one as a session: one hook-running probe per folder finds + * these commands and later scans reuse its answer. + */ +export type ClaudeHookSlashCommandsByCwd = ReadonlyMap< + string, + ReadonlyArray +>; + +interface ClaudeHookSlashCommands { + readonly byCwd: Ref.Ref; + /** False for callers that may not run the user's hooks; they still get what is remembered. */ + readonly discover: boolean; +} + export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* ( claudeSettings: ClaudeSettings, machineSnapshot: ServerProvider, cwd: string, environment?: NodeJS.ProcessEnv, + hookCommands?: ClaudeHookSlashCommands, ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); const fileSystem = yield* FileSystem.FileSystem; // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); - const probeResult = cwdExists - ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + const probe = (runHooks: boolean) => + probeClaudeCapabilities(claudeSettings, environment, cwd, false, runHooks).pipe(Effect.result); + const knownHookCommands = hookCommands + ? (yield* Ref.get(hookCommands.byCwd)).get(cwd) : undefined; + const [probeResult, hookProbeResult] = cwdExists + ? yield* Effect.all( + [ + probe(false), + hookCommands?.discover && knownHookCommands === undefined + ? probe(true) + : Effect.undefined, + ], + { concurrency: "unbounded" }, + ) + : [undefined, undefined]; const capabilities = probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; + let hookSlashCommands = knownHookCommands ?? []; + // A failed hook probe is not remembered, so the next scan tries again. + if (hookCommands && capabilities && hookProbeResult && Result.isSuccess(hookProbeResult)) { + const hookless = new Set(capabilities.slashCommands.map(({ name }) => name.toLowerCase())); + const discovered = hookProbeResult.success.slashCommands.filter( + ({ name }) => !hookless.has(name.toLowerCase()), + ); + hookSlashCommands = discovered; + yield* Ref.update(hookCommands.byCwd, (byCwd) => new Map(byCwd).set(cwd, discovered)); + } return { ...machineSnapshot, skills, slashCommands: dedupeSlashCommands([ COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? []), + ...hookSlashCommands, ]), slashCommandsPending: cwdExists && !capabilities, }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index af20b76754943..de4ce9f6d8311 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -32,6 +32,7 @@ import { probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, type ClaudeCapabilitiesProbe, + type ClaudeHookSlashCommandsByCwd, } from "../ClaudeProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; @@ -56,6 +57,7 @@ import { } from "./ClaudeHome.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { makeClaudeMaintenanceResolution } from "./ClaudeMaintenance.ts"; +import { WorkspaceScanRunsHooks } from "../WorkspaceScanHooks.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -146,6 +148,7 @@ export const ClaudeDriver: ProviderDriver(new Map()); const orchestrationAdapter = yield* createClaudeAdapterV2( { instanceId, @@ -381,11 +384,16 @@ export const ClaudeDriver: ProviderDriver - snapshot.getSnapshot.pipe( - Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), + Effect.all([snapshot.getSnapshot, WorkspaceScanRunsHooks]).pipe( + Effect.flatMap(([machineSnapshot, discover]) => + probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv, { + byCwd: hookSlashCommandsByCwd, + discover, + }), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 564a90f40e6ce..505710db75873 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -82,6 +82,7 @@ import type { } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; +import { WorkspaceScanRunsHooks } from "./WorkspaceScanHooks.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -2288,6 +2289,7 @@ it.layer( skills: [], } as const satisfies ServerProvider; const scannedDirectories = yield* Ref.make>([]); + const scansRunningHooks = yield* Ref.make>([]); const instance: ProviderInstance = { instanceId, driverKind: driver, @@ -2309,6 +2311,10 @@ it.layer( }, snapshotForCwd: (directory) => Ref.update(scannedDirectories, (directories) => [...directories, directory]).pipe( + Effect.andThen(WorkspaceScanRunsHooks), + Effect.flatMap((runHooks) => + Ref.update(scansRunningHooks, (scans) => [...scans, runHooks]), + ), Effect.as({ ...provider, skills: [{ name: "finalize", path: `${directory}/finalize`, enabled: true }], @@ -2351,6 +2357,11 @@ it.layer( scanCwd: "/project", }); assert.deepStrictEqual(yield* Ref.get(scannedDirectories), ["/project"]); + // The scan is detached from its caller, whose permission to run hooks must still reach it. + yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/operated" }) + .pipe(Effect.provideService(WorkspaceScanRunsHooks, true)); + assert.deepStrictEqual(yield* Ref.get(scansRunningHooks), [false, true]); assert.deepStrictEqual( providers[0]?.workspaceSnapshots ?.find((snapshot) => snapshot.cwd === "/worktree") diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index ba9ce9b2b6af7..f0f242a0d6ff8 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -29,6 +29,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ServerConfig from "../config.ts"; +import { WorkspaceScanRunsHooks } from "./WorkspaceScanHooks.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import { @@ -1073,6 +1074,8 @@ export const layer = Layer.effect( const snapshotForCwd = instance?.snapshotForCwd; if (!instance || !snapshotForCwd) return providers; const token = yield* Deferred.make(); + // Read here: the scan below runs detached from this caller. + const runHooks = yield* WorkspaceScanRunsHooks; // A machine refresh can change this cwd's snapshot, so the scan publishes against the result. const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( @@ -1087,10 +1090,12 @@ export const layer = Layer.effect( : Effect.succeed(scannedFrom); const runScan = refreshMachineSnapshot.pipe( Effect.flatMap((scanBase) => - Effect.map(snapshotForCwd(input.scanCwd ?? input.cwd), (scopedSnapshot) => ({ - scanBase, - scopedSnapshot, - })), + Effect.map( + snapshotForCwd(input.scanCwd ?? input.cwd).pipe( + Effect.provideService(WorkspaceScanRunsHooks, runHooks), + ), + (scopedSnapshot) => ({ scanBase, scopedSnapshot }), + ), ), // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap(({ scanBase, scopedSnapshot }) => diff --git a/apps/server/src/provider/WorkspaceScanHooks.ts b/apps/server/src/provider/WorkspaceScanHooks.ts new file mode 100644 index 0000000000000..da3a484ab724a --- /dev/null +++ b/apps/server/src/provider/WorkspaceScanHooks.ts @@ -0,0 +1,10 @@ +import * as Context from "effect/Context"; + +/** + * A workspace scan that runs session hooks executes the user's or the project's code in a cwd the + * caller names, so only callers that may operate the environment set this. + */ +export const WorkspaceScanRunsHooks = Context.Reference( + "t3/provider/WorkspaceScanRunsHooks", + { defaultValue: () => false }, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f2a148734d4e..0f743e7409e3e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -175,6 +175,7 @@ import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErr import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as SecretCheck from "./secrets/SecretCheck.ts"; import * as AcpRegistrySessions from "./provider/AcpRegistrySessions.ts"; +import { WorkspaceScanRunsHooks } from "./provider/WorkspaceScanHooks.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; @@ -2442,12 +2443,19 @@ export const layerWsRpc = ( yield* usageLimitSources.refresh; } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - scanCwd: yield* projectService.workspaceScanDirectory(input.cwd), - fresh: input.fresh === true, - }) + ? providerRegistry + .refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + scanCwd: yield* projectService.workspaceScanDirectory(input.cwd), + fresh: input.fresh === true, + }) + .pipe( + Effect.provideService( + WorkspaceScanRunsHooks, + currentSession.scopes.includes(AuthOrchestrationOperateScope), + ), + ) : input.reloadSecrets === true ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) : input.instanceId !== undefined