From 8715256acf418a0f4e7b4c4658d0c0c48e4f4820 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:55:22 +0000 Subject: [PATCH 1/2] fix(server): commands a Claude Code mod registers show in the / menu once a session has run The / menu is built from a capabilities probe that runs with hooks off, so a command a mod registers in its session-start hook was never listed. Probes stay hookless (#3050, #4466). Instead, a live Claude session, where hooks already run, reports its command list on init; the driver keeps it per folder and the workspace snapshot for that folder adds what the probe could not see. Fixes pingdotgg/t3code#17418 Co-Authored-By: Claude Fable 5.2 --- .../Adapters/ClaudeAdapterV2.test.ts | 99 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 42 +++++++- .../provider/ClaudeCapabilitiesProbe.test.ts | 6 ++ apps/server/src/provider/ClaudeProvider.ts | 5 +- .../src/provider/Drivers/ClaudeDriver.ts | 34 ++++++- 5 files changed, 179 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 155370da1757d..cff7f41eba160 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1098,6 +1098,105 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); }); +// regression: #17418 hookless capability probes never list commands a SessionStart hook registers +it.effect("reports a live session's slash commands for its folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-commands-cwd-" }); + const reported = yield* Deferred.make<{ + readonly cwd: string; + readonly names: ReadonlyArray; + }>(); + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-commands-" }), + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator: yield* IdAllocator.IdAllocatorV2, + onSessionSlashCommands: (commandCwd, commands) => + Deferred.succeed(reported, { + cwd: commandCwd, + names: commands.map((command) => command.name), + }).pipe(Effect.asVoid), + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-commands"), + open: () => + Effect.succeed({ + messages: Stream.concat( + Stream.make({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000017418", + session_id: "native-thread-claude-commands", + } as unknown as SDKMessage), + Stream.never, + ), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + supportedCommands: Effect.succeed([ + { name: "handoffs", description: "Show handoffs", argumentHint: "" }, + ]), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }); + const threadId = ThreadId.make("thread-claude-commands"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-commands"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-commands"), + text: "hello", + attachments: [], + runtimePolicy, + }), + ); + assert.deepEqual(yield* Deferred.await(reported), { cwd, names: ["handoffs"] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), +); + describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { const startTurnWithLaunchArgs = Effect.fnUntraced(function* (launchArgs: string, cwd: string) { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b3c1fd1c1fed4..bc144db4e47aa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -64,6 +64,7 @@ import { type SDKRateLimitInfo, type SDKResultMessage, type SDKUserMessage, + type SlashCommand, } from "@anthropic-ai/claude-agent-sdk"; import type { AskUserQuestionInput, @@ -672,6 +673,11 @@ export interface ClaudeAgentSdkQuerySession { ReadonlyArray, ClaudeAgentSdkQueryRunnerError >; + /** The session's slash commands, including the ones its SessionStart hooks registered. */ + readonly supportedCommands?: Effect.Effect< + ReadonlyArray, + ClaudeAgentSdkQueryRunnerError + >; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -1686,6 +1692,10 @@ export const layerQueryRunner: Layer.Layer< }), catch: (cause) => queryRunnerError(cause, "callMcpTool"), }), + supportedCommands: Effect.tryPromise({ + try: () => queryRuntime.supportedCommands(), + catch: (cause) => queryRunnerError(cause, "supportedCommands"), + }), listPermissionRules: Effect.tryPromise({ try: () => sendClaudeControlRequest(queryRuntime, { subtype: "list_permission_rules" }), @@ -5256,6 +5266,11 @@ export interface ClaudeAdapterV2Options { readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + /** Capability probes run without hooks, so only a live session lists hook-registered commands. */ + readonly onSessionSlashCommands?: ( + cwd: string, + commands: ReadonlyArray, + ) => Effect.Effect; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -12276,6 +12291,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Stream.runForEach((message) => sdkMessageLock.withPermits(1)( Effect.suspend(() => { + const { onSessionSlashCommands } = adapterOptions; + const sessionCwd = turnInput.runtimePolicy.cwd; + // Detached: the list is optional and must not hold up the message stream. + const reportSlashCommands = + onSessionSlashCommands && + sessionCwd && + querySession.supportedCommands && + message.type === "system" && + message.subtype === "init" && + !context.receivedInit + ? querySession.supportedCommands.pipe( + Effect.flatMap((commands) => + onSessionSlashCommands(sessionCwd, commands), + ), + Effect.ignore, + Effect.forkDetach, + ) + : Effect.void; if ( message.type === "system" && message.subtype === "init" && @@ -12319,7 +12352,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultsReportingNewContextWindow.add(message); } } - return handleSdkMessage({ query: querySession, message }); + return reportSlashCommands.pipe( + Effect.andThen(handleSdkMessage({ query: querySession, message })), + ); }), ), ), @@ -13732,7 +13767,10 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick< + ClaudeAdapterV2Options, + "scopedLimitNames" | "onUsageLimits" | "onSessionSlashCommands" | "modelCatalog" + > = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index bf3c9318877bb..daafa4dc4f113 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -42,8 +42,14 @@ 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" }, + [{ name: "handoffs" }, { name: "Compact", description: "duplicate of the built-in" }], ); assert.strictEqual(snapshot.slashCommandsPending, false); + // The hookless probe cannot list what a session's hooks registered, so a live session does. + assert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact", "handoffs"], + ); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18885f0417ebb..6d21220671b1f 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -344,7 +344,7 @@ function parseClaudeEffortCapSources( return sources.length > 0 ? sources : undefined; } -function parseClaudeInitializationCommands( +export function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { return dedupeSlashCommands( @@ -588,6 +588,8 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps machineSnapshot: ServerProvider, cwd: string, environment?: NodeJS.ProcessEnv, + /** The probe runs without hooks, so a live session in `cwd` supplies what its hooks registered. */ + sessionSlashCommands: ReadonlyArray = [], ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); @@ -605,6 +607,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps slashCommands: dedupeSlashCommands([ COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? []), + ...sessionSlashCommands, ]), slashCommandsPending: cwdExists && !capabilities, }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index af20b76754943..c5f73a2f70b3e 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,4 +1,8 @@ -import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { + ClaudeSettings, + ProviderDriverKind, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -29,6 +33,7 @@ import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, + parseClaudeInitializationCommands, probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, type ClaudeCapabilitiesProbe, @@ -60,6 +65,7 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +const MAX_SESSION_SLASH_COMMAND_CWDS = 64; const withoutUsage = (capabilities: ClaudeCapabilitiesProbe): ClaudeCapabilitiesProbe => { const result = { ...capabilities }; @@ -146,6 +152,9 @@ export const ClaudeDriver: ProviderDriver> + >(new Map()); const orchestrationAdapter = yield* createClaudeAdapterV2( { instanceId, @@ -158,6 +167,17 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update), + onSessionSlashCommands: (cwd, commands) => + Ref.update(sessionSlashCommands, (byCwd) => { + const next = new Map(byCwd); + next.delete(cwd); + next.set(cwd, parseClaudeInitializationCommands(commands)); + // Oldest folders go first; a later session there records them again. + for (const stale of [...next.keys()].slice(0, -MAX_SESSION_SLASH_COMMAND_CWDS)) { + next.delete(stale); + } + return next; + }), modelCatalog, }, ).pipe( @@ -383,9 +403,15 @@ export const ClaudeDriver: ProviderDriver - snapshot.getSnapshot.pipe( - Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), + Effect.all([snapshot.getSnapshot, Ref.get(sessionSlashCommands)]).pipe( + Effect.flatMap(([machineSnapshot, byCwd]) => + probeClaudeWorkspaceSnapshot( + effectiveConfig, + machineSnapshot, + cwd, + processEnv, + byCwd.get(cwd), + ), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), From 65c12f4fece376bf9b3ad38065960c9fcda9e949 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:13:23 +0000 Subject: [PATCH 2/2] fix(server): find mod commands with one hook-running probe per folder Waiting for a live session left a new thread without the command before its first turn and for minutes after it. Workspace discovery for callers that may operate the environment now runs one extra probe per folder with hooks on, remembers the commands it adds, and merges them into every later scan. Health checks and routine rescans stay hookless. A fresh refresh forgets the remembered commands and discovers them again. This replaces the live-session reporting from the previous commit. Co-Authored-By: Claude Fable 5.2 --- .../Adapters/ClaudeAdapterV2.test.ts | 99 ------------------- .../Adapters/ClaudeAdapterV2.ts | 42 +------- .../provider/ClaudeCapabilitiesProbe.test.ts | 55 +++++++++-- apps/server/src/provider/ClaudeProvider.ts | 59 +++++++++-- .../src/provider/Drivers/ClaudeDriver.ts | 44 +++------ .../src/provider/ProviderRegistry.test.ts | 11 +++ apps/server/src/provider/ProviderRegistry.ts | 13 ++- .../server/src/provider/WorkspaceScanHooks.ts | 10 ++ apps/server/src/ws.ts | 20 ++-- 9 files changed, 159 insertions(+), 194 deletions(-) create mode 100644 apps/server/src/provider/WorkspaceScanHooks.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index cff7f41eba160..155370da1757d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1098,105 +1098,6 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); }); -// regression: #17418 hookless capability probes never list commands a SessionStart hook registers -it.effect("reports a live session's slash commands for its folder", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-commands-cwd-" }); - const reported = yield* Deferred.make<{ - readonly cwd: string; - readonly names: ReadonlyArray; - }>(); - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-commands-" }), - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator: yield* IdAllocator.IdAllocatorV2, - onSessionSlashCommands: (commandCwd, commands) => - Deferred.succeed(reported, { - cwd: commandCwd, - names: commands.map((command) => command.name), - }).pipe(Effect.asVoid), - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-commands"), - open: () => - Effect.succeed({ - messages: Stream.concat( - Stream.make({ - type: "system", - subtype: "init", - uuid: "00000000-0000-4000-8000-000000017418", - session_id: "native-thread-claude-commands", - } as unknown as SDKMessage), - Stream.never, - ), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - supportedCommands: Effect.succeed([ - { name: "handoffs", description: "Show handoffs", argumentHint: "" }, - ]), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd, - }); - const threadId = ThreadId.make("thread-claude-commands"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-commands"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-commands"), - text: "hello", - attachments: [], - runtimePolicy, - }), - ); - assert.deepEqual(yield* Deferred.await(reported), { cwd, names: ["handoffs"] }); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), -); - describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { const startTurnWithLaunchArgs = Effect.fnUntraced(function* (launchArgs: string, cwd: string) { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bc144db4e47aa..b3c1fd1c1fed4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -64,7 +64,6 @@ import { type SDKRateLimitInfo, type SDKResultMessage, type SDKUserMessage, - type SlashCommand, } from "@anthropic-ai/claude-agent-sdk"; import type { AskUserQuestionInput, @@ -673,11 +672,6 @@ export interface ClaudeAgentSdkQuerySession { ReadonlyArray, ClaudeAgentSdkQueryRunnerError >; - /** The session's slash commands, including the ones its SessionStart hooks registered. */ - readonly supportedCommands?: Effect.Effect< - ReadonlyArray, - ClaudeAgentSdkQueryRunnerError - >; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -1692,10 +1686,6 @@ export const layerQueryRunner: Layer.Layer< }), catch: (cause) => queryRunnerError(cause, "callMcpTool"), }), - supportedCommands: Effect.tryPromise({ - try: () => queryRuntime.supportedCommands(), - catch: (cause) => queryRunnerError(cause, "supportedCommands"), - }), listPermissionRules: Effect.tryPromise({ try: () => sendClaudeControlRequest(queryRuntime, { subtype: "list_permission_rules" }), @@ -5266,11 +5256,6 @@ export interface ClaudeAdapterV2Options { readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; - /** Capability probes run without hooks, so only a live session lists hook-registered commands. */ - readonly onSessionSlashCommands?: ( - cwd: string, - commands: ReadonlyArray, - ) => Effect.Effect; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -12291,24 +12276,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Stream.runForEach((message) => sdkMessageLock.withPermits(1)( Effect.suspend(() => { - const { onSessionSlashCommands } = adapterOptions; - const sessionCwd = turnInput.runtimePolicy.cwd; - // Detached: the list is optional and must not hold up the message stream. - const reportSlashCommands = - onSessionSlashCommands && - sessionCwd && - querySession.supportedCommands && - message.type === "system" && - message.subtype === "init" && - !context.receivedInit - ? querySession.supportedCommands.pipe( - Effect.flatMap((commands) => - onSessionSlashCommands(sessionCwd, commands), - ), - Effect.ignore, - Effect.forkDetach, - ) - : Effect.void; if ( message.type === "system" && message.subtype === "init" && @@ -12352,9 +12319,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultsReportingNewContextWindow.add(message); } } - return reportSlashCommands.pipe( - Effect.andThen(handleSdkMessage({ query: querySession, message })), - ); + return handleSdkMessage({ query: querySession, message }); }), ), ), @@ -13767,10 +13732,7 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick< - ClaudeAdapterV2Options, - "scopedLimitNames" | "onUsageLimits" | "onSessionSlashCommands" | "modelCatalog" - > = {}, + hooks: Pick = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index daafa4dc4f113..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, @@ -42,17 +44,58 @@ 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" }, - [{ name: "handoffs" }, { name: "Compact", description: "duplicate of the built-in" }], ); assert.strictEqual(snapshot.slashCommandsPending, false); - // The hookless probe cannot list what a session's hooks registered, so a live session does. - assert.deepEqual( - snapshot.slashCommands.map((command) => command.name), - ["compact", "handoffs"], - ); }).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 6d21220671b1f..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: {}, @@ -344,7 +346,7 @@ function parseClaudeEffortCapSources( return sources.length > 0 ? sources : undefined; } -export function parseClaudeInitializationCommands( +function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { return dedupeSlashCommands( @@ -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,31 +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, - /** The probe runs without hooks, so a live session in `cwd` supplies what its hooks registered. */ - sessionSlashCommands: ReadonlyArray = [], + 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 ?? []), - ...sessionSlashCommands, + ...hookSlashCommands, ]), slashCommandsPending: cwdExists && !capabilities, }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c5f73a2f70b3e..de4ce9f6d8311 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,8 +1,4 @@ -import { - ClaudeSettings, - ProviderDriverKind, - type ServerProviderSlashCommand, -} from "@t3tools/contracts"; +import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -33,10 +29,10 @@ import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, - parseClaudeInitializationCommands, 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"; @@ -61,11 +57,11 @@ 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"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); -const MAX_SESSION_SLASH_COMMAND_CWDS = 64; const withoutUsage = (capabilities: ClaudeCapabilitiesProbe): ClaudeCapabilitiesProbe => { const result = { ...capabilities }; @@ -152,9 +148,7 @@ export const ClaudeDriver: ProviderDriver> - >(new Map()); + const hookSlashCommandsByCwd = yield* Ref.make(new Map()); const orchestrationAdapter = yield* createClaudeAdapterV2( { instanceId, @@ -167,17 +161,6 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update), - onSessionSlashCommands: (cwd, commands) => - Ref.update(sessionSlashCommands, (byCwd) => { - const next = new Map(byCwd); - next.delete(cwd); - next.set(cwd, parseClaudeInitializationCommands(commands)); - // Oldest folders go first; a later session there records them again. - for (const stale of [...next.keys()].slice(0, -MAX_SESSION_SLASH_COMMAND_CWDS)) { - next.delete(stale); - } - return next; - }), modelCatalog, }, ).pipe( @@ -401,17 +384,16 @@ export const ClaudeDriver: ProviderDriver - Effect.all([snapshot.getSnapshot, Ref.get(sessionSlashCommands)]).pipe( - Effect.flatMap(([machineSnapshot, byCwd]) => - probeClaudeWorkspaceSnapshot( - effectiveConfig, - machineSnapshot, - cwd, - processEnv, - byCwd.get(cwd), - ), + 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