diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index bf3c9318877bb..cdad76ae7cb75 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,11 +1,19 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; +import { + ClaudeSettings, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { buildServerProvider } from "@t3tools/provider-core/server/snapshotProbe"; import { vi } from "vite-plus/test"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -42,6 +50,7 @@ it.effect("reports a deleted worktree's scan as finished, not pending", () => { skills: [], slashCommands: [] } as unknown as ServerProvider, "/nonexistent/t3-deleted-worktree", { HOME: "/nonexistent/t3-home" }, + false, ); assert.strictEqual(snapshot.slashCommandsPending, false); }).pipe(Effect.provide(NodeServices.layer)), @@ -88,3 +97,126 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi assert.equal(abortSignal?.aborted, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +// Mirrors Claude Code 2.1.295 and 2.1.303: initialize answers only after +// session.start hooks finish, so a mod's registered command is in init unless +// hooks are disabled, in which case the hook never runs. +const mockClaudeWithMod = (hook: "registers" | "never-finishes" = "registers") => { + const hookRuns = { count: 0 }; + let markStarted = () => {}; + const started = new Promise((resolve) => (markStarted = resolve)); + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + const hooksRun = + options?.settings === undefined || typeof options.settings === "string" + ? true + : options.settings.disableAllHooks !== true; + if (hooksRun) hookRuns.count += 1; + markStarted(); + const builtin = { name: "project-review", description: "", argumentHint: "" }; + const modCommand = { name: "handoffs", description: "", argumentHint: "" }; + // oxlint-disable-next-line require-yield -- the stream stays open until abort, like the real SDK query + const messages = (async function* () { + await new Promise((resolve) => + options?.abortController?.signal.addEventListener("abort", resolve), + ); + })(); + return Object.assign(messages, { + initializationResult: () => + hooksRun && hook === "never-finishes" + ? new Promise(() => {}) + : Promise.resolve({ + commands: hooksRun ? [builtin, modCommand] : [builtin], + account: {}, + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + throw new Error("usage unavailable"); + }, + }) as unknown as ReturnType; + }); + return { query, hookRuns, started }; +}; + +const MACHINE_SNAPSHOT = { + ...buildServerProvider({ + presentation: { displayName: "Claude" }, + enabled: true, + checkedAt: "2026-10-22T00:00:00.000Z", + models: [], + slashCommands: [{ name: "machine-only" }], + probe: { + installed: true, + version: "2.1.303", + status: "ready", + auth: { status: "authenticated" }, + }, + }), + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), +}; + +const scanWorkspace = (hook: "registers" | "never-finishes", runHooks: boolean) => + Effect.gen(function* () { + const { query, hookRuns, started } = mockClaudeWithMod(hook); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const scan = yield* probeClaudeWorkspaceSnapshot( + yield* decodeClaudeSettings({}), + MACHINE_SNAPSHOT, + NodeOS.tmpdir(), + {}, + runHooks, + ).pipe(Effect.forkChild); + // The probe deadline runs on the test clock, so a hook that never + // finishes reaches it without any real waiting. + yield* Effect.promise(() => started); + yield* TestClock.adjust("30 seconds"); + const snapshot = yield* Fiber.join(scan); + return { + commands: snapshot.slashCommands.map((command) => command.name), + pending: snapshot.slashCommandsPending, + hookRuns: hookRuns.count, + }; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect("a scan that may run hooks lists commands that mods register in session.start", () => + Effect.gen(function* () { + const scan = yield* scanWorkspace("registers", true); + assert.include(scan.commands, "handoffs"); + assert.equal(scan.pending, false); + }), +); + +it.effect("a scan for a read-only caller does not run session.start hooks", () => + Effect.gen(function* () { + const scan = yield* scanWorkspace("registers", false); + assert.equal(scan.hookRuns, 0); + assert.notInclude(scan.commands, "handoffs"); + assert.include(scan.commands, "project-review"); + }), +); + +it.effect("a hook that outlasts the probe still lists the project's own commands", () => + Effect.gen(function* () { + const scan = yield* scanWorkspace("never-finishes", true); + assert.equal(scan.hookRuns, 1); + assert.sameMembers(scan.commands, ["compact", "project-review"]); + assert.equal(scan.pending, false); + }), +); + +it.effect("the periodic health check does not run session.start hooks", () => + Effect.gen(function* () { + const { query, hookRuns } = mockClaudeWithMod(); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const probe = yield* probeClaudeCapabilities( + yield* decodeClaudeSettings({}), + {}, + undefined, + true, + ); + assert.equal(hookRuns.count, 0); + assert.notInclude( + probe.slashCommands.map((command) => command.name), + "handoffs", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 72221ff3187b4..773f58ebbd880 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -224,14 +224,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 }, + // Health checks fire every few minutes, so they must not run SessionStart hooks. Workspace + // command discovery for operators runs them because mods register commands from one. + ...(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: {}, @@ -371,6 +373,7 @@ const probeClaudeCapabilities = ( environment?: NodeJS.ProcessEnv, cwd?: string, includeUsage = true, + runHooks = false, ) => { const abort = new AbortController(); const abortProbe = Effect.sync(() => { @@ -403,8 +406,10 @@ const probeClaudeCapabilities = ( environment: claudeEnvironment, cwd, managedMcpConfig, + runHooks, }), }); + // Claude Code answers initialize after SessionStart hooks finish, so init lists mod commands. const init = await q.initializationResult(); return { q, init, graceMs }; }, @@ -492,16 +497,20 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps claudeSettings: ClaudeSettings, machineSnapshot: ServerProvider, cwd: string, - environment?: NodeJS.ProcessEnv, + environment: NodeJS.ProcessEnv | undefined, + runHooks: boolean, ): 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) - : undefined; + const probe = (hooks: boolean) => + probeClaudeCapabilities(claudeSettings, environment, cwd, false, hooks).pipe(Effect.result); + let probeResult = cwdExists ? yield* probe(runHooks) : undefined; + // A hook can outlast the probe deadline. Probing again without hooks still lists the + // project's own commands; a failure of both stays pending so clients retry. + if (runHooks && probeResult && Result.isFailure(probeResult)) probeResult = yield* probe(false); const capabilities = probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; return { @@ -512,6 +521,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps ...(capabilities?.slashCommands ?? []), ]), slashCommandsPending: cwdExists && !capabilities, + hooksSkipped: !runHooks, }; }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 3ada12297649d..cc2f23bb11569 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -56,6 +56,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"); @@ -384,9 +385,15 @@ export const ClaudeDriver: ProviderDriver - snapshot.getSnapshot.pipe( - Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), + Effect.all([snapshot.getSnapshot, WorkspaceScanRunsHooks]).pipe( + Effect.flatMap(([machineSnapshot, runHooks]) => + probeClaudeWorkspaceSnapshot( + effectiveConfig, + machineSnapshot, + cwd, + processEnv, + runHooks, + ), ), 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 5c53f76237b6a..9c2e37438a0bb 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -56,6 +56,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; +import { WorkspaceScanRunsHooks } from "./WorkspaceScanHooks.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; import * as PortScanner from "../preview/PortScanner.ts"; @@ -1949,6 +1950,151 @@ it.layer( assert.deepStrictEqual(recovered?.skills, laterSkills); assert.deepStrictEqual(recovered?.slashCommands, [COMPACT_SLASH_COMMAND]); assert.strictEqual(recovered?.slashCommandsPending, undefined); + + // Codex scans run no hooks, so a read-only caller still refreshes + // an expired scan an operator published. + yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); + yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) + .pipe(Effect.provideService(WorkspaceScanRunsHooks, true)); + yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); + yield* Ref.set(scanResult, { ...healthyProvider, skills: firstSkills }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.deepStrictEqual((yield* workspaceOf)?.skills, firstSkills); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("a hook-free workspace scan does not stand in for one that may run hooks", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [COMPACT_SLASH_COMMAND], + skills: [], + } as const satisfies ServerProvider; + const modCommand = { name: "handoffs" }; + const hookScans = yield* Ref.make(0); + const hookFreeScans = yield* Ref.make(0); + const hookScanStarted = yield* Deferred.make(); + const releaseHookScan = yield* Deferred.make(); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "claude:instance" }, + 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: () => + Effect.gen(function* () { + if (!(yield* WorkspaceScanRunsHooks)) { + yield* Ref.update(hookFreeScans, (count) => count + 1); + return { ...provider, slashCommandsPending: false, hooksSkipped: true }; + } + yield* Ref.update(hookScans, (count) => count + 1); + yield* Deferred.succeed(hookScanStarted, undefined); + yield* Deferred.await(releaseHookScan); + return { + ...provider, + slashCommands: [COMPACT_SLASH_COMMAND, modCommand], + slashCommandsPending: false, + hooksSkipped: false, + }; + }), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-hook-scan-", + }), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const scan = registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const operatorScan = scan.pipe(Effect.provideService(WorkspaceScanRunsHooks, true)); + const workspaceSnapshot = Effect.map( + registry.getProviders, + (providers) => providers[0]?.workspaceSnapshots?.[0], + ); + const commandNames = Effect.map(workspaceSnapshot, (snapshot) => + snapshot?.slashCommands.map((command) => command.name), + ); + + // A read-only caller scans without hooks; another reuses that scan. + yield* scan; + yield* scan; + assert.strictEqual(yield* Ref.get(hookFreeScans), 1); + assert.deepStrictEqual(yield* commandNames, ["compact"]); + // Clients read this to ask an operator's server for a hook scan. + assert.strictEqual((yield* workspaceSnapshot)?.hooksSkipped, true); + + // An operator does not reuse it inside the TTL, and a read-only + // caller arriving meanwhile joins the scan instead of starting one. + const operator = yield* Effect.forkChild(operatorScan); + yield* Effect.raceFirst(Deferred.await(hookScanStarted), Fiber.join(operator)); + assert.strictEqual(yield* Ref.get(hookScans), 1); + const reader = yield* Effect.forkChild(scan, { startImmediately: true }); + yield* Deferred.succeed(releaseHookScan, undefined); + yield* Fiber.join(operator); + yield* Fiber.join(reader); + assert.strictEqual(yield* Ref.get(hookFreeScans), 1); + assert.deepStrictEqual(yield* commandNames, ["compact", "handoffs"]); + assert.strictEqual((yield* workspaceSnapshot)?.hooksSkipped, false); + + // The full inventory then serves both kinds of caller. + yield* scan; + yield* operatorScan; + assert.strictEqual(yield* Ref.get(hookScans), 1); + assert.strictEqual(yield* Ref.get(hookFreeScans), 1); + + // Once stale, a read-only caller still does not swap it for fewer commands. + yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); + yield* scan; + assert.strictEqual(yield* Ref.get(hookFreeScans), 1); + assert.deepStrictEqual(yield* commandNames, ["compact", "handoffs"]); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a0ea5c78fbc61..838645ded04ea 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -27,6 +27,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 { @@ -164,6 +165,9 @@ export function upsertProviderWorkspaceSnapshot( ? (previousSnapshot?.slashCommands ?? scopedSnapshot.slashCommands) : scopedSnapshot.slashCommands, ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), + ...(scopedSnapshot.hooksSkipped === undefined + ? {} + : { hooksSkipped: scopedSnapshot.hooksSkipped }), skills: scopedSnapshot.skills, ...(optionDescriptors === undefined ? {} : { optionDescriptors }), } satisfies NonNullable[number]; @@ -441,7 +445,10 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap>> + ReadonlyMap< + ProviderInstance, + ReadonlyMap; readonly runHooks: boolean }> + > >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -970,13 +977,22 @@ export const layer = Layer.effect( const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); const scannedFrom = workspaceSnapshotOf(provider); + const runHooks = yield* WorkspaceScanRunsHooks; const now = yield* DateTime.now; if ( !provider || !provider.enabled || + // A hook-free scan would replace commands that hooks registered with + // fewer, so a read-only caller keeps the full inventory, even stale. + // Only drivers whose scans run hooks report `hooksSkipped: false`. + (!runHooks && + scannedFrom && + !scannedFrom.slashCommandsPending && + scannedFrom.hooksSkipped === false) || (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending && + !(runHooks && scannedFrom.hooksSkipped) && isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) ) { return providers; @@ -985,14 +1001,18 @@ export const layer = Layer.effect( const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; - const token = yield* Deferred.make(); + const token = { done: yield* Deferred.make(), runHooks }; const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; const runScan = refreshMachineSnapshot.pipe( - Effect.andThen(instance.snapshotForCwd(input.scanCwd ?? input.cwd)), + Effect.andThen( + instance + .snapshotForCwd(input.scanCwd ?? input.cwd) + .pipe(Effect.provideService(WorkspaceScanRunsHooks, runHooks)), + ), // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => instanceRegistry.getInstance(input.instanceId).pipe( @@ -1029,7 +1049,7 @@ export const layer = Layer.effect( if (nextCwds.size) next.set(instance, nextCwds); else next.delete(instance); return next; - }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), + }).pipe(Effect.andThen(Deferred.succeed(token.done, undefined))), ), ); // The claim and the fork happen together, so the token is always completed. The scan is @@ -1039,7 +1059,10 @@ export const layer = Layer.effect( const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { const current = refreshes.get(instance); const running = current?.get(input.cwd); - if (!input.fresh && running) return [running, refreshes] as const; + // Join a scan unless it skips hooks this caller may run. A hook-free caller never + // replaces a scan that runs hooks, even when fresh. + const joins = running?.runHooks ? !input.fresh || !runHooks : !input.fresh && !runHooks; + if (running && joins) return [running.done, refreshes] as const; const next = new Map(refreshes); next.set(instance, new Map(current).set(input.cwd, token)); return [undefined, next] as const; diff --git a/apps/server/src/provider/WorkspaceScanHooks.ts b/apps/server/src/provider/WorkspaceScanHooks.ts new file mode 100644 index 0000000000000..e572cc87684c2 --- /dev/null +++ b/apps/server/src/provider/WorkspaceScanHooks.ts @@ -0,0 +1,12 @@ +import * as Context from "effect/Context"; + +/** + * A workspace scan may start a provider session in any cwd the caller names, + * and session hooks run the user's or the project's code there. A read-only + * caller still gets the inventory a hook-free scan finds; 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 9721864fcdfd5..97e6fc4b7eb36 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -174,6 +174,7 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.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"; @@ -2513,12 +2514,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 diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index bd5729656c841..a8ba18f19e98d 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -267,6 +267,19 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it("rescans a current snapshot that a read-only client scanned without hooks", () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); + const [scanned] = withSnapshot("2026-01-01T00:00:00.000Z"); + const { run, unmount } = setup([ + { + ...scanned!, + workspaceSnapshots: [{ ...scanned!.workspaceSnapshots![0]!, hooksSkipped: true }], + }, + ]); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + it("cools down a failed scan, then retries on its own while the scan stays pending", async () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z", true)); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 2598c895c5053..057f49890f4fd 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -114,6 +114,10 @@ function hasCurrentProviderWorkspaceSnapshot( return Boolean( snapshot && !snapshot.slashCommandsPending && + // A read-only client's scan may lack hook commands. Asking again is + // cheap: the server answers read-only callers from this snapshot and + // rescans with hooks only for callers that may operate. + !snapshot.hooksSkipped && isProviderWorkspaceSnapshotCurrent(snapshot, nowMs), ); } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 40a1aa8b2ca86..b14a4d17bc627 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -127,6 +127,11 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ checkedAt: IsoDateTime, slashCommands: Schema.Array(ServerProviderSlashCommand), slashCommandsPending: Schema.optional(Schema.Boolean), + /** + * A read-only client's scan skips session hooks, which can register + * commands. Clients that may operate the environment rescan such a snapshot. + */ + hooksSkipped: Schema.optional(Schema.Boolean), skills: Schema.Array(ServerProviderSkill), optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), }); diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 3fee55c6fbcab..bbb238c71bb44 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -30,7 +30,10 @@ export interface ProviderDriverMetadata { } export type ProviderWorkspaceSnapshot = ServerProvider & - Pick; + Pick< + ServerProviderWorkspaceSnapshot, + "slashCommandsPending" | "hooksSkipped" | "optionDescriptors" + >; export interface ProviderInstance { readonly instanceId: ProviderInstanceId;