From ca332bb20a8d9ceb2e035484ec70600c277f7dc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:16:36 +0000 Subject: [PATCH 1/9] test(server): Claude probe lists commands that mods register Failing test for #17418. Co-Authored-By: Claude Opus 5.5 --- .../provider/ClaudeCapabilitiesProbe.test.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 80740b0a21d49..5a8fd7ba83014 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,11 +1,20 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ClaudeSettings } from "@t3tools/contracts"; +import { Effect, Schema } from "effect"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; -import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; +import { + buildClaudeCapabilitiesProbeQueryOptions, + probeClaudeCapabilities, +} from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); +const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); + it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ executablePath: "/usr/bin/claude", @@ -20,3 +29,44 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); + +it.effect("lists commands that mods register in their session.start hook", () => + Effect.gen(function* () { + const builtin = { name: "review", description: "", argumentHint: "" }; + const modCommand = { name: "handoffs", description: "", argumentHint: "" }; + // Mirrors Claude Code 2.1.303: a mod's session.start hook registers its + // command after init, so init omits it and a commands_changed push carries + // it. With all hooks disabled the hook never runs and the command never + // appears. + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + const hooksRun = + options?.settings === undefined || typeof options.settings === "string" + ? true + : options.settings.disableAllHooks !== true; + let commands = [builtin]; + // The SDK transport tracks the push even when nobody reads the stream. + const registered = Promise.resolve().then(() => { + if (hooksRun) commands = [builtin, modCommand]; + }); + const messages = (async function* () { + await registered; + if (hooksRun) yield { type: "system", subtype: "commands_changed", commands } as never; + await new Promise((resolve) => + options?.abortController?.signal.addEventListener("abort", resolve), + ); + })(); + return Object.assign(messages, { + initializationResult: async () => ({ commands: [builtin], account: {} }), + supportedCommands: async () => commands, + }) as unknown as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + + const probe = yield* probeClaudeCapabilities(DEFAULT_CLAUDE_SETTINGS, {}, undefined, false); + + assert.include( + probe.slashCommands.map((command) => command.name), + "handoffs", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); From 1fa8ee05f7af797f608f853843f65cfa7a65aba1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:20:30 +0000 Subject: [PATCH 2/9] fix(server): Claude composer lists slash commands that mods register Workspace command discovery now runs hooks and reads supportedCommands(), which tracks the commands_changed push a mod's session.start hook causes. The periodic health check still disables all hooks. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/ClaudeProvider.ts | 23 +++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18f3b8508a04a..00770150034e1 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -209,16 +209,17 @@ 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], - // The probe keeps filesystem setting sources for slash-command discovery, - // but must not run the user's hooks: it fires every few minutes, so - // SessionStart hooks would run on every health check. - settings: { disableAllHooks: true }, + // The health check fires every few minutes, so it must not run the user's + // SessionStart hooks. Workspace command discovery runs them because mods + // register their slash commands from a session.start hook. + ...(input.runHooks ? {} : { settings: { disableAllHooks: true } }), allowedTools: [], // Ignore MCP definitions from every filesystem setting source above. The // SDK combines this empty explicit map with --strict-mcp-config, which @@ -350,7 +351,7 @@ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, - includeUsage = true, + healthCheck = true, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -374,20 +375,24 @@ const probeClaudeCapabilities = ( environment: claudeEnvironment, cwd, managedMcpConfig, + runHooks: !healthCheck, }), }); const init = await q.initializationResult(); - return { q, init }; + // Mod commands arrive in a commands_changed push after init, which + // supportedCommands() tracks. + const commands = healthCheck ? init.commands : await q.supportedCommands(); + return { q, init, commands }; }); }).pipe( Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.flatMap(({ q, init }) => + Effect.flatMap(({ q, init, commands }) => Effect.gen(function* () { // Usage has its own deadline so a slow optional request cannot discard initialization. // Only the rate limits are read, so skip the local transcript scan that fills // `behaviors`: with a few GB of transcripts it outlasts the deadline. const usageCheckedAt = DateTime.formatIso(yield* DateTime.now); - const usageResult = includeUsage + const usageResult = healthCheck ? yield* Effect.tryPromise(() => q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }), ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) @@ -414,7 +419,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, - slashCommands: parseClaudeInitializationCommands(init.commands), + slashCommands: parseClaudeInitializationCommands(commands), ...(usage ? { usage, usageCheckedAt } : {}), } satisfies ClaudeCapabilitiesProbe; }), From 18bfc441c2d511aa9b2e030ae8aac7115f3187d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:29:11 +0000 Subject: [PATCH 3/9] fix(server): read mod commands from init and pin health check hook-off Claude Code answers initialize only after session.start hooks finish, so init already lists commands that mods register there; verified against CLI 2.1.295 and 2.1.303. Drop the supportedCommands() read, which only mirrors init until a later push and implied a guarantee it cannot keep. Model that protocol in the probe test and assert the periodic health check still runs no hooks. Co-Authored-By: Claude Opus 5.5 --- .../provider/ClaudeCapabilitiesProbe.test.ts | 80 +++++++++++-------- apps/server/src/provider/ClaudeProvider.ts | 11 ++- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 5a8fd7ba83014..f2197dde75de1 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -30,43 +30,55 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); -it.effect("lists commands that mods register in their session.start hook", () => - Effect.gen(function* () { +// 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 = () => + vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + const hooksRun = + options?.settings === undefined || typeof options.settings === "string" + ? true + : options.settings.disableAllHooks !== true; const builtin = { name: "review", description: "", argumentHint: "" }; const modCommand = { name: "handoffs", description: "", argumentHint: "" }; - // Mirrors Claude Code 2.1.303: a mod's session.start hook registers its - // command after init, so init omits it and a commands_changed push carries - // it. With all hooks disabled the hook never runs and the command never - // appears. - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - const hooksRun = - options?.settings === undefined || typeof options.settings === "string" - ? true - : options.settings.disableAllHooks !== true; - let commands = [builtin]; - // The SDK transport tracks the push even when nobody reads the stream. - const registered = Promise.resolve().then(() => { - if (hooksRun) commands = [builtin, modCommand]; - }); - const messages = (async function* () { - await registered; - if (hooksRun) yield { type: "system", subtype: "commands_changed", commands } as never; - await new Promise((resolve) => - options?.abortController?.signal.addEventListener("abort", resolve), - ); - })(); - return Object.assign(messages, { - initializationResult: async () => ({ commands: [builtin], account: {} }), - supportedCommands: async () => commands, - }) as unknown as ReturnType; - }); + // 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: async () => ({ + 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; + }); + +const probeCommandNames = (healthCheck: boolean) => + Effect.gen(function* () { + const query = mockClaudeWithMod(); yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const probe = yield* probeClaudeCapabilities( + DEFAULT_CLAUDE_SETTINGS, + {}, + undefined, + healthCheck, + ); + return probe.slashCommands.map((command) => command.name); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); - const probe = yield* probeClaudeCapabilities(DEFAULT_CLAUDE_SETTINGS, {}, undefined, false); +it.effect("workspace discovery lists commands that mods register in session.start", () => + Effect.gen(function* () { + assert.include(yield* probeCommandNames(false), "handoffs"); + }), +); - assert.include( - probe.slashCommands.map((command) => command.name), - "handoffs", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +it.effect("the periodic health check does not run session.start hooks", () => + Effect.gen(function* () { + assert.notInclude(yield* probeCommandNames(true), "handoffs"); + }), ); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 00770150034e1..44472abcbfcc5 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -378,15 +378,14 @@ const probeClaudeCapabilities = ( runHooks: !healthCheck, }), }); + // Claude Code answers initialize only after session.start hooks finish, + // so init already lists the commands mods register there. const init = await q.initializationResult(); - // Mod commands arrive in a commands_changed push after init, which - // supportedCommands() tracks. - const commands = healthCheck ? init.commands : await q.supportedCommands(); - return { q, init, commands }; + return { q, init }; }); }).pipe( Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.flatMap(({ q, init, commands }) => + Effect.flatMap(({ q, init }) => Effect.gen(function* () { // Usage has its own deadline so a slow optional request cannot discard initialization. // Only the rate limits are read, so skip the local transcript scan that fills @@ -419,7 +418,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, - slashCommands: parseClaudeInitializationCommands(commands), + slashCommands: parseClaudeInitializationCommands(init.commands), ...(usage ? { usage, usageCheckedAt } : {}), } satisfies ClaudeCapabilitiesProbe; }), From f8fe323dfa5b730e217a5bd4295f3d377f2b2e91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:46:17 +0000 Subject: [PATCH 4/9] fix(server): only operators run hooks in Claude workspace scans The workspace scan now runs session.start hooks so mod commands show up, but refreshProviders only needs orchestration:read and accepts any cwd. Hooks now run only for sessions with orchestration:operate. Read-only callers still get a hook-free inventory. An operator does not reuse a cached hook-free scan, and a read-only caller cannot start or replace the operator's scan. A hook that outlasts the 25s probe deadline no longer marks commands pending, which made clients rescan, and rerun the hook, every 10s. The scan now settles on the health check's commands. Co-Authored-By: Claude Opus 5.5 --- .../provider/ClaudeCapabilitiesProbe.test.ts | 107 +++++++++++++--- apps/server/src/provider/ClaudeProvider.ts | 23 ++-- .../src/provider/Drivers/ClaudeDriver.ts | 14 ++- .../src/provider/ProviderRegistry.test.ts | 119 ++++++++++++++++++ apps/server/src/provider/ProviderRegistry.ts | 52 +++++--- .../server/src/provider/WorkspaceScanHooks.ts | 12 ++ apps/server/src/ws.ts | 18 ++- 7 files changed, 294 insertions(+), 51 deletions(-) create mode 100644 apps/server/src/provider/WorkspaceScanHooks.ts diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index f2197dde75de1..451c17aa13a5a 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,14 +1,18 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ClaudeSettings } from "@t3tools/contracts"; -import { Effect, Schema } from "effect"; +import { ClaudeSettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { buildServerProvider } from "@t3tools/provider-core/server/snapshotProbe"; +import { Effect, Fiber, Schema } from "effect"; +import { TestClock } from "effect/testing"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; import { buildClaudeCapabilitiesProbeQueryOptions, probeClaudeCapabilities, + probeClaudeWorkspaceSnapshot, } from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -33,12 +37,17 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp // 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 = () => - vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { +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: "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 @@ -48,37 +57,97 @@ const mockClaudeWithMod = () => ); })(); return Object.assign(messages, { - initializationResult: async () => ({ - commands: hooksRun ? [builtin, modCommand] : [builtin], - account: {}, - }), + 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 probeCommandNames = (healthCheck: boolean) => +const MACHINE_SNAPSHOT = { + ...buildServerProvider({ + presentation: { displayName: "Claude" }, + enabled: true, + checkedAt: "2026-10-22T00:00:00.000Z", + models: [], + slashCommands: [{ name: "review" }], + 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 = mockClaudeWithMod(); + const { query, hookRuns, started } = mockClaudeWithMod(hook); yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( + const scan = yield* probeClaudeWorkspaceSnapshot( DEFAULT_CLAUDE_SETTINGS, + MACHINE_SNAPSHOT, + NodeOS.tmpdir(), {}, - undefined, - healthCheck, - ); - return probe.slashCommands.map((command) => command.name); + 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("workspace discovery lists commands that mods register in session.start", () => +it.effect("a scan that may run hooks lists commands that mods register in session.start", () => Effect.gen(function* () { - assert.include(yield* probeCommandNames(false), "handoffs"); + const scan = yield* scanWorkspace("registers", true); + assert.include(scan.commands, "handoffs"); + assert.equal(scan.pending, false); }), ); -it.effect("the periodic health check does not run session.start hooks", () => +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, "review"); + }), +); + +it.effect("a hook that outlasts the probe keeps the machine commands and is not retried", () => Effect.gen(function* () { - assert.notInclude(yield* probeCommandNames(true), "handoffs"); + const scan = yield* scanWorkspace("never-finishes", true); + assert.equal(scan.hookRuns, 1); + assert.includeMembers(scan.commands, ["compact", "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(DEFAULT_CLAUDE_SETTINGS, {}, 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 44472abcbfcc5..7e6eff1dcfd84 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -352,6 +352,7 @@ const probeClaudeCapabilities = ( environment?: NodeJS.ProcessEnv, cwd?: string, healthCheck = true, + runHooks = false, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -375,7 +376,7 @@ const probeClaudeCapabilities = ( environment: claudeEnvironment, cwd, managedMcpConfig, - runHooks: !healthCheck, + runHooks, }), }); // Claude Code answers initialize only after session.start hooks finish, @@ -458,22 +459,30 @@ 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 probeResult = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe( - Effect.result, - ); + const probeResult = yield* probeClaudeCapabilities( + claudeSettings, + environment, + cwd, + false, + runHooks, + ).pipe(Effect.result); const capabilities = Result.isSuccess(probeResult) ? probeResult.success : undefined; + // A hook can outlast the probe deadline. Retrying would rerun it every few + // seconds, so settle on the health check's hook-free commands instead. + const settled = capabilities !== undefined || runHooks; return { ...machineSnapshot, skills, slashCommands: dedupeSlashCommands([ COMPACT_SLASH_COMMAND, - ...(capabilities?.slashCommands ?? []), + ...(capabilities?.slashCommands ?? (runHooks ? machineSnapshot.slashCommands : [])), ]), - slashCommandsPending: !capabilities, + slashCommandsPending: !settled, }; }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 86832d53d3119..60d0a07970e54 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -61,6 +61,8 @@ import { resolveClaudeHomePath, } from "./ClaudeHome.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { WorkspaceScanRunsHooks } from "../WorkspaceScanHooks.ts"; + const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -397,9 +399,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 a278470c286d0..0ea10513c007e 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -48,6 +48,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 ServerConfig from "../config.ts"; @@ -1372,6 +1373,124 @@ it.layer( }), ); + 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 }; + } + 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, + }; + }), + 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.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-hook-scan-", + }), + ), + Layer.provideMerge(NodeServices.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 commandNames = Effect.map(registry.getProviders, (providers) => + providers[0]?.workspaceSnapshots?.[0]?.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"]); + + // 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); + yield* scan; + assert.strictEqual(yield* Ref.get(hookFreeScans), 1); + yield* Deferred.succeed(releaseHookScan, undefined); + yield* Fiber.join(operator); + assert.deepStrictEqual(yield* commandNames, ["compact", "handoffs"]); + + // 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); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97da44aa93ed6..c2bc43ce0aaa4 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -25,6 +25,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 { hydrateCachedProvider, @@ -423,8 +424,11 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap> >(new Map()); + // Snapshots a scan published without running hooks. A caller that may + // run hooks rescans them rather than reusing their partial commands. + const hookFreeWorkspaceSnapshots = new WeakSet(); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -932,6 +936,7 @@ 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 || @@ -939,6 +944,7 @@ export const layer = Layer.effect( (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending && + !(runHooks && hookFreeWorkspaceSnapshots.has(scannedFrom)) && isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) ) { return providers; @@ -948,10 +954,16 @@ export const layer = Layer.effect( const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; - const token = Symbol(); + const token = { runHooks }; const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { const current = refreshes.get(instance); - if (!input.fresh && current?.has(input.cwd)) return [false, refreshes] as const; + const inFlight = current?.get(input.cwd); + // 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 = inFlight?.runHooks ? !input.fresh || !runHooks : !input.fresh && !runHooks; + if (inFlight && joins) { + return [false, refreshes] as const; + } const next = new Map(refreshes); next.set(instance, new Map(current).set(input.cwd, token)); return [true, next] as const; @@ -975,20 +987,26 @@ export const layer = Layer.effect( if (Ref.getUnsafe(workspaceRefreshesRef).get(instance)?.get(input.cwd) !== token) { return currentProviders; } - return currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) - ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { - ...scopedSnapshot, - checkedAt: scannedAt, - // Unless the driver reports its own command discovery, - // commands come from the health check. A failed check - // leaves them unknown, so keep the last ones and retry. - slashCommandsPending: - scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", - }) - : candidate, - ); + return currentProviders.map((candidate) => { + if ( + candidate.instanceId !== input.instanceId || + !Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) + ) { + return candidate; + } + const next = upsertProviderWorkspaceSnapshot(candidate, input.cwd, { + ...scopedSnapshot, + checkedAt: scannedAt, + // Unless the driver reports its own command discovery, + // commands come from the health check. A failed check + // leaves them unknown, so keep the last ones and retry. + slashCommandsPending: + scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", + }); + const published = workspaceSnapshotOf(next); + if (published && !runHooks) hookFreeWorkspaceSnapshots.add(published); + return next; + }); }); }), ), 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 1ba4ff99ff644..3c4ac337dfe30 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -159,6 +159,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"; @@ -2212,11 +2213,18 @@ export const layerWsRpc = ( yield* usageLimitSources.refresh; } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - fresh: input.fresh === true, - }) + ? providerRegistry + .refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh === true, + }) + .pipe( + Effect.provideService( + WorkspaceScanRunsHooks, + currentSession.scopes.includes(AuthOrchestrationOperateScope), + ), + ) : input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) : providerRegistry.refresh(); From 03bd4fbf945fd01e68a46c98ca46469efc4a5aa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:52:54 +0000 Subject: [PATCH 5/9] fix(server): operator clients rescan workspaces a read-only scan skipped hooks in Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ProviderRegistry.test.ts | 17 +++++++++++++++-- apps/server/src/provider/ProviderRegistry.ts | 18 ++++++++++-------- .../client-runtime/src/providerSkills.test.ts | 16 ++++++++++++++++ packages/client-runtime/src/providerSkills.ts | 4 ++++ packages/contracts/src/server.ts | 5 +++++ packages/provider-core/src/server/driver.ts | 5 ++++- 6 files changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 0ea10513c007e..1e9cecf112c33 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1461,8 +1461,12 @@ it.layer( const registry = yield* ProviderRegistry.ProviderRegistry; const scan = registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); const operatorScan = scan.pipe(Effect.provideService(WorkspaceScanRunsHooks, true)); - const commandNames = Effect.map(registry.getProviders, (providers) => - providers[0]?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), + 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. @@ -1470,6 +1474,8 @@ it.layer( 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. @@ -1481,12 +1487,19 @@ it.layer( yield* Deferred.succeed(releaseHookScan, undefined); yield* Fiber.join(operator); assert.deepStrictEqual(yield* commandNames, ["compact", "handoffs"]); + assert.strictEqual((yield* workspaceSnapshot)?.hooksSkipped, undefined); // 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 c2bc43ce0aaa4..cbdaa90974f4c 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -141,6 +141,7 @@ export function upsertProviderWorkspaceSnapshot( ? (previousSnapshot?.slashCommands ?? scopedSnapshot.slashCommands) : scopedSnapshot.slashCommands, ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), + ...(scopedSnapshot.hooksSkipped ? { hooksSkipped: true } : {}), skills: scopedSnapshot.skills, ...(optionDescriptors === undefined ? {} : { optionDescriptors }), } satisfies NonNullable[number]; @@ -426,9 +427,6 @@ export const layer = Layer.effect( const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); - // Snapshots a scan published without running hooks. A caller that may - // run hooks rescans them rather than reusing their partial commands. - const hookFreeWorkspaceSnapshots = new WeakSet(); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); @@ -941,10 +939,16 @@ export const layer = Layer.effect( 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. + (!runHooks && + scannedFrom && + !scannedFrom.slashCommandsPending && + !scannedFrom.hooksSkipped) || (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending && - !(runHooks && hookFreeWorkspaceSnapshots.has(scannedFrom)) && + !(runHooks && scannedFrom.hooksSkipped) && isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) ) { return providers; @@ -994,8 +998,9 @@ export const layer = Layer.effect( ) { return candidate; } - const next = upsertProviderWorkspaceSnapshot(candidate, input.cwd, { + return upsertProviderWorkspaceSnapshot(candidate, input.cwd, { ...scopedSnapshot, + hooksSkipped: !runHooks, checkedAt: scannedAt, // Unless the driver reports its own command discovery, // commands come from the health check. A failed check @@ -1003,9 +1008,6 @@ export const layer = Layer.effect( slashCommandsPending: scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", }); - const published = workspaceSnapshotOf(next); - if (published && !runHooks) hookFreeWorkspaceSnapshots.add(published); - return next; }); }); }), diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 198779af001d7..645e4c9482f52 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + hasCurrentProviderWorkspaceSnapshot, resolveProviderForCwd, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, @@ -123,6 +124,21 @@ describe("workspace provider snapshots", () => { expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false); expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); }); + + it("asks again for a current snapshot that a read-only client scanned without hooks", () => { + const now = Date.parse(provider.workspaceSnapshots[0]!.checkedAt); + const hookFree = { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + hooksSkipped: true, + })), + } satisfies ServerProvider; + expect(hasCurrentProviderWorkspaceSnapshot(provider, "/workspace/project-a", now)).toBe(true); + expect(hasCurrentProviderWorkspaceSnapshot(hookFree, "/workspace/project-a", now)).toBe(false); + // Commands stay usable meanwhile, so the request cannot retry in a loop. + expect(hasCompleteProviderWorkspaceSnapshot(hookFree, "/workspace/project-a")).toBe(true); + }); }); describe("resolveProviderForCwd", () => { diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 61be359529b7a..2c117e1aa491b 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -110,6 +110,10 @@ export 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 c39a29d6fe776..a4391380ad4aa 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -133,6 +133,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 88355ccaf4171..40019dd5e7d56 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -37,7 +37,10 @@ export interface ProviderDriverMetadata { } export type ProviderWorkspaceSnapshot = ServerProvider & - Pick; + Pick< + ServerProviderWorkspaceSnapshot, + "slashCommandsPending" | "hooksSkipped" | "optionDescriptors" + >; export interface ProviderInstance { readonly instanceId: ProviderInstanceId; From 4bfaa82d99829736a38c39643f5987f3ffb8c764 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:53:26 +0000 Subject: [PATCH 6/9] test(server): add reusable Claude handoffs fixture --- scripts/seed-claude-handoffs-fixture.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 scripts/seed-claude-handoffs-fixture.ts diff --git a/scripts/seed-claude-handoffs-fixture.ts b/scripts/seed-claude-handoffs-fixture.ts new file mode 100644 index 0000000000000..47d06145d3e46 --- /dev/null +++ b/scripts/seed-claude-handoffs-fixture.ts @@ -0,0 +1,22 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +// Keep the fixture in sandbox state; never seed the running user's T3 home. +const root = NodePath.resolve(import.meta.dirname, "../.t3/repro-mod"); +await NodeFSP.mkdir(NodePath.join(root, "hooks"), { recursive: true }); +await NodeFSP.writeFile(NodePath.join(root, "hooks/hooks.json"), '{"modules":["./register.ts"]}\n'); +await NodeFSP.writeFile( + NodePath.join(root, "hooks/register.ts"), + `import type { Register } from 'claude-code'; +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({name:'handoffs',description:'Issue 17418 fixture command'}); + return next(e); + }); + on('command.run', {command:'handoffs'}, async () => ({text:'HANDOFFS_FIXTURE_OK'})); +}; +`, +); +console.log(`Fixture: ${root}`); +console.log(`Start dev with CLAUDE_CODE_PLUGIN_DIRS=${root}, select Claude, then type /hand.`); +console.log("Select /handoffs and send; expect HANDOFFS_FIXTURE_OK."); From 7bc11a59279ad5668fac35b0a606c9570f709231 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 20:14:51 +0000 Subject: [PATCH 7/9] fix(server): only Claude workspace scans mark themselves hook-free The registry stamped every provider's scan with hooksSkipped from the caller, and refused any read-only rescan of a snapshot without it. Codex and other drivers never run hooks, so after one operator scan a read-only client kept that snapshot past its TTL indefinitely. The Claude probe now reports hooksSkipped itself. The registry passes it through, still upgrades hook-skipped snapshots for operators, and refuses a read-only rescan only of a snapshot that reports hooksSkipped: false. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/ClaudeProvider.ts | 1 + .../server/src/provider/ProviderRegistry.test.ts | 16 ++++++++++++++-- apps/server/src/provider/ProviderRegistry.ts | 8 +++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 7e6eff1dcfd84..9fa18510ad9c5 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -483,6 +483,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps ...(capabilities?.slashCommands ?? (runHooks ? machineSnapshot.slashCommands : [])), ]), slashCommandsPending: !settled, + hooksSkipped: !runHooks, }; }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 1e9cecf112c33..c8de556fcf36b 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1369,6 +1369,17 @@ 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)); }), ); @@ -1418,7 +1429,7 @@ it.layer( Effect.gen(function* () { if (!(yield* WorkspaceScanRunsHooks)) { yield* Ref.update(hookFreeScans, (count) => count + 1); - return { ...provider, slashCommandsPending: false }; + return { ...provider, slashCommandsPending: false, hooksSkipped: true }; } yield* Ref.update(hookScans, (count) => count + 1); yield* Deferred.succeed(hookScanStarted, undefined); @@ -1427,6 +1438,7 @@ it.layer( ...provider, slashCommands: [COMPACT_SLASH_COMMAND, modCommand], slashCommandsPending: false, + hooksSkipped: false, }; }), orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], @@ -1487,7 +1499,7 @@ it.layer( yield* Deferred.succeed(releaseHookScan, undefined); yield* Fiber.join(operator); assert.deepStrictEqual(yield* commandNames, ["compact", "handoffs"]); - assert.strictEqual((yield* workspaceSnapshot)?.hooksSkipped, undefined); + assert.strictEqual((yield* workspaceSnapshot)?.hooksSkipped, false); // The full inventory then serves both kinds of caller. yield* scan; diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index cbdaa90974f4c..7c4fb84e3a51e 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -141,7 +141,9 @@ export function upsertProviderWorkspaceSnapshot( ? (previousSnapshot?.slashCommands ?? scopedSnapshot.slashCommands) : scopedSnapshot.slashCommands, ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), - ...(scopedSnapshot.hooksSkipped ? { hooksSkipped: true } : {}), + ...(scopedSnapshot.hooksSkipped === undefined + ? {} + : { hooksSkipped: scopedSnapshot.hooksSkipped }), skills: scopedSnapshot.skills, ...(optionDescriptors === undefined ? {} : { optionDescriptors }), } satisfies NonNullable[number]; @@ -941,10 +943,11 @@ export const layer = Layer.effect( !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) || + scannedFrom.hooksSkipped === false) || (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending && @@ -1000,7 +1003,6 @@ export const layer = Layer.effect( } return upsertProviderWorkspaceSnapshot(candidate, input.cwd, { ...scopedSnapshot, - hooksSkipped: !runHooks, checkedAt: scannedAt, // Unless the driver reports its own command discovery, // commands come from the health check. A failed check From b9cffcfea136b071b8e12889420adb06afa1e99f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 20:30:40 +0000 Subject: [PATCH 8/9] chore(scripts): move Claude handoffs seeder into issue-17418 fixtures The TypeScript seeder failed knip (unused file) and the scripts workspace typecheck (Effect nodeBuiltinImport/globalConsole rules). As plain JS beside the issue-16534 fixtures it stays out of tsc, and a knip entry marks it as run by hand. It writes the same mod and prints the same steps. Co-Authored-By: Claude Opus 5.5 --- knip.jsonc | 2 ++ .../issue-17418/seed.js} | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) rename scripts/{seed-claude-handoffs-fixture.ts => fixtures/issue-17418/seed.js} (92%) diff --git a/knip.jsonc b/knip.jsonc index babc22932ca0d..c8292d62f39f4 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -24,6 +24,8 @@ "entry": [ // These fixture scripts are injected into a browser page by path. "fixtures/issue-16534/*.js", + // Run by hand to seed the issue 17418 Claude mod fixture. + "fixtures/issue-17418/*.js", "knip-schemas.ts", "mobile-native-client.ts", "setup-worktree.ts", diff --git a/scripts/seed-claude-handoffs-fixture.ts b/scripts/fixtures/issue-17418/seed.js similarity index 92% rename from scripts/seed-claude-handoffs-fixture.ts rename to scripts/fixtures/issue-17418/seed.js index 47d06145d3e46..e9909bf75e71e 100644 --- a/scripts/seed-claude-handoffs-fixture.ts +++ b/scripts/fixtures/issue-17418/seed.js @@ -2,7 +2,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; // Keep the fixture in sandbox state; never seed the running user's T3 home. -const root = NodePath.resolve(import.meta.dirname, "../.t3/repro-mod"); +const root = NodePath.resolve(import.meta.dirname, "../../../.t3/repro-mod"); await NodeFSP.mkdir(NodePath.join(root, "hooks"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hooks/hooks.json"), '{"modules":["./register.ts"]}\n'); await NodeFSP.writeFile( From a6eccc2d99ebcac318a3e6a4995969e6c02502b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:42:52 +0000 Subject: [PATCH 9/9] fix(server): a slow Claude hook no longer hides project commands When the hook-running workspace probe fails, probe again without hooks in the same directory instead of settling on the health check's machine-level commands. A failure of both stays pending so clients retry, as before. AI-assisted; verified with the tests and tools listed above. --- .../provider/ClaudeCapabilitiesProbe.test.ts | 10 +++++----- apps/server/src/provider/ClaudeProvider.ts | 18 ++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 11a47f392e0de..cdad76ae7cb75 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -112,7 +112,7 @@ const mockClaudeWithMod = (hook: "registers" | "never-finishes" = "registers") = : options.settings.disableAllHooks !== true; if (hooksRun) hookRuns.count += 1; markStarted(); - const builtin = { name: "review", description: "", argumentHint: "" }; + 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* () { @@ -142,7 +142,7 @@ const MACHINE_SNAPSHOT = { enabled: true, checkedAt: "2026-10-22T00:00:00.000Z", models: [], - slashCommands: [{ name: "review" }], + slashCommands: [{ name: "machine-only" }], probe: { installed: true, version: "2.1.303", @@ -190,15 +190,15 @@ it.effect("a scan for a read-only caller does not run session.start hooks", () = const scan = yield* scanWorkspace("registers", false); assert.equal(scan.hookRuns, 0); assert.notInclude(scan.commands, "handoffs"); - assert.include(scan.commands, "review"); + assert.include(scan.commands, "project-review"); }), ); -it.effect("a hook that outlasts the probe keeps the machine commands and is not retried", () => +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.includeMembers(scan.commands, ["compact", "review"]); + assert.sameMembers(scan.commands, ["compact", "project-review"]); assert.equal(scan.pending, false); }), ); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 6eea5a1f017c5..773f58ebbd880 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -505,24 +505,22 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps 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, runHooks).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; - // A hook can outlast the probe deadline. Retrying would rerun it every few seconds, so settle - // on the health check's hook-free commands instead. - const settled = capabilities !== undefined || runHooks; return { ...machineSnapshot, skills, slashCommands: dedupeSlashCommands([ COMPACT_SLASH_COMMAND, - ...(capabilities?.slashCommands ?? (runHooks ? machineSnapshot.slashCommands : [])), + ...(capabilities?.slashCommands ?? []), ]), - slashCommandsPending: cwdExists && !settled, + slashCommandsPending: cwdExists && !capabilities, hooksSkipped: !runHooks, }; });