Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
// Reads a thread, its project's workspace root, and that directory's
// skill definitions. Read-only, so it sits with the other read methods.
[WS_METHODS.providersWorkspaceSkills]: AuthOrchestrationReadScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
Expand Down
46 changes: 38 additions & 8 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,15 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
import { makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping this import leaves makeClaudeCapabilitiesCacheKey in ClaudeHome.ts with no production caller — the only remaining references are in ClaudeHome.test.ts. Suggest deleting the helper and its now-obsolete test cases along with the cache-key change so no dead pre-refactor path is retained.

Posted via Macroscope — Effect Service Conventions

import { discoverClaudeSkills } from "./ClaudeSkills.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
// One entry per workspace the user has open. Small enough to bound CLI
// spawns, large enough that switching between projects does not re-probe.
const CAPABILITIES_PROBE_CACHE_CAPACITY = 16;

function isClaudeNativeCommandPath(commandPath: string): boolean {
const normalized = normalizeCommandPath(commandPath);
Expand Down Expand Up @@ -151,21 +155,27 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv);

// Per-instance capabilities cache: keyed on binary + resolved HOME so
// account-specific probes never share auth metadata across instances.
// Per-instance capabilities cache, keyed by working directory.
//
// This cache lives in one instance's scope, so binary path and resolved
// HOME are already constant here — they cannot leak account metadata
// across instances. The only thing that varies is the cwd, and it
// genuinely does vary: the CLI's init handshake reports PROJECT-scoped
// slash commands, so a probe run from the server's own cwd sees only the
// built-ins. Keying on cwd lets each workspace get its own answer while
// still costing one CLI spawn per workspace per TTL.
const capabilitiesProbeCache = yield* Cache.make({
capacity: 1,
capacity: CAPABILITIES_PROBE_CACHE_CAPACITY,
timeToLive: CAPABILITIES_PROBE_TTL,
lookup: () =>
probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
lookup: (probeCwd: string) =>
probeClaudeCapabilities(effectiveConfig, processEnv, probeCwd).pipe(
Effect.provideService(Path.Path, path),
),
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);

const checkProvider = checkClaudeProviderStatus(
effectiveConfig,
() => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
() => Cache.get(capabilitiesProbeCache, cwd),
processEnv,
cwd,
).pipe(
Expand Down Expand Up @@ -216,6 +226,26 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
snapshot,
adapter,
textGeneration,
// Project-scoped skills. The snapshot's own `skills` are scanned once
// against `ServerConfig.cwd`, which a packaged build sets to the home
// directory, so it can never see a project's `.claude/skills`. This is
// a filesystem scan, not a CLI probe, so it is cheap enough to run per
// request and needs no cache.
discoverSkillsForCwd: (skillsCwd: string) =>
discoverClaudeSkills(effectiveConfig, skillsCwd, processEnv).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
// Slash commands come from the CLI's init handshake, so this spawns a
// process. It goes through the same cwd-keyed cache the snapshot probe
// uses, which bounds it to one spawn per workspace per TTL. A failed
// probe resolves empty so the caller falls back to the snapshot rather
// than surfacing an error in a picker.
discoverSlashCommandsForCwd: (commandsCwd: string) =>
Cache.get(capabilitiesProbeCache, commandsCwd).pipe(
Effect.map((capabilities) => capabilities?.slashCommands ?? []),
Effect.orElseSucceed(() => []),
),
} satisfies ProviderInstance;
}),
};
31 changes: 31 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
ProviderDriverKind,
type ProviderInstanceId,
type ServerProvider,
type ServerProviderSkill,
type ServerProviderSlashCommand,
type ServerProviderUpdateState,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -508,6 +510,33 @@ export const ProviderRegistryLive = Layer.effect(
);
});

const discoverSkillsForInstance = Effect.fn("discoverSkillsForInstance")(function* (
instanceId: ProviderInstanceId,
cwd: string,
) {
const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find(
(candidate) => candidate.instanceId === instanceId,
);
// Not live, or a driver with no directory-scoped skill concept: resolve
// empty so the caller falls back to the snapshot instead of failing.
if (!instance?.discoverSkillsForCwd) {
return [] as ReadonlyArray<ServerProviderSkill>;
}
return yield* instance.discoverSkillsForCwd(cwd);
});

const discoverSlashCommandsForInstance = Effect.fn("discoverSlashCommandsForInstance")(
function* (instanceId: ProviderInstanceId, cwd: string) {
const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find(
(candidate) => candidate.instanceId === instanceId,
);
if (!instance?.discoverSlashCommandsForCwd) {
return [] as ReadonlyArray<ServerProviderSlashCommand>;
}
return yield* instance.discoverSlashCommandsForCwd(cwd);
},
);

/**
* Diff the aggregator's live-source set against the current
* `ProviderInstanceRegistry` and:
Expand Down Expand Up @@ -711,6 +740,8 @@ export const ProviderRegistryLive = Layer.effect(
refreshInstance: (instanceId: ProviderInstanceId) =>
refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
getProviderMaintenanceCapabilitiesForInstance,
discoverSkillsForInstance,
discoverSlashCommandsForInstance,
setProviderMaintenanceActionState,
get streamChanges() {
return Stream.fromPubSub(changesPubSub);
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/provider/ProviderDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type {
ProviderDriverKind,
ProviderInstanceEnvironment,
ProviderInstanceId,
ServerProviderSkill,
ServerProviderSlashCommand,
} from "@t3tools/contracts";
import type * as Effect from "effect/Effect";
import type * as Schema from "effect/Schema";
Expand Down Expand Up @@ -71,6 +73,37 @@ export interface ProviderInstance {
readonly snapshot: ServerProviderShape;
readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
readonly textGeneration: TextGeneration.TextGeneration["Service"];
/**
* Skills visible from a specific workspace root.
*
* `snapshot.skills` is machine-scoped: it is produced once per instance
* against the server's own cwd, which for a packaged desktop build is the
* user's home directory. Skills are project-scoped, so that snapshot can
* only ever report the user-scope ones. Callers that know which project
* they are asking about (a thread's worktree or its project's workspace
* root) use this instead, and drivers that can enumerate skills per
* directory implement it.
*
* Optional: a driver that has no directory-scoped skill concept simply
* omits it and callers fall back to the snapshot.
*/
readonly discoverSkillsForCwd?: (
cwd: string,
) => Effect.Effect<ReadonlyArray<ServerProviderSkill>>;
/**
* Slash commands visible from a specific workspace root.
*
* Same scoping problem as `discoverSkillsForCwd`, different source: these
* come from the agent CLI's own init handshake, which reports PROJECT-scoped
* commands. `snapshot.slashCommands` is probed once against the server's cwd
* and therefore lists only the CLI's built-ins.
*
* Unlike the skills scan this spawns the CLI, so implementations should
* cache per directory rather than probe per call.
*/
readonly discoverSlashCommandsForCwd?: (
cwd: string,
) => Effect.Effect<ReadonlyArray<ServerProviderSlashCommand>>;
}

export interface ProviderContinuationIdentity {
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/provider/Services/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
ProviderInstanceId,
ProviderDriverKind,
ServerProvider,
ServerProviderSkill,
ServerProviderSlashCommand,
ServerProviderUpdateState,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -57,6 +59,36 @@ export interface ProviderRegistryShape {
provider: ProviderDriverKind,
) => Effect.Effect<ProviderMaintenanceCapabilities>;

/**
* Skills one live instance can see from a specific workspace root.
*
* `ServerProvider.skills` is machine-scoped — scanned once against the
* server's own cwd, which a packaged desktop build sets to the home
* directory — so it reports user-scope skills only. Callers holding a
* project context ask here instead.
*
* Resolves to an empty array when the instance is not live or its driver
* has no directory-scoped skill concept; callers then fall back to the
* snapshot rather than treating it as an error.
*/
readonly discoverSkillsForInstance: (
instanceId: ProviderInstanceId,
cwd: string,
) => Effect.Effect<ReadonlyArray<ServerProviderSkill>>;

/**
* Slash commands one live instance can see from a specific workspace root.
*
* Same scoping story as `discoverSkillsForInstance`, but sourced from the
* agent CLI's init handshake rather than the filesystem, so it may spawn a
* process. Resolves empty when the instance is not live or its driver cannot
* enumerate commands per directory.
*/
readonly discoverSlashCommandsForInstance: (
instanceId: ProviderInstanceId,
cwd: string,
) => Effect.Effect<ReadonlyArray<ServerProviderSlashCommand>>;

/**
* Apply volatile maintenance-action state to one configured instance.
* This state is never persisted to disk. Today only update actions are
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ function makeRegistry(
refreshInstance: () => Ref.get(providersRef),
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
Effect.succeed(lifecycleFor(provider)),
discoverSkillsForInstance: () => Effect.succeed([]),
discoverSlashCommandsForInstance: () => Effect.succeed([]),
setProviderMaintenanceActionState,
streamChanges: Stream.empty,
};
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/testUtils/providerRegistryMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const makeProviderRegistryMock = (
refreshInstance: () => Effect.succeed(providers),
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })),
discoverSkillsForInstance: () => Effect.succeed([]),
discoverSlashCommandsForInstance: () => Effect.succeed([]),
setProviderMaintenanceActionState: () => Effect.succeed(providers),
streamChanges: Stream.empty,
});
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1934,6 +1934,57 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.providersWorkspaceSkills]: (input) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is new backend behavior (workspace-root resolution where a persisted thread's worktree overrides the project root, an unknown project failing as OrchestrationGetSnapshotError, an unknown thread tolerated, and the two registry discovery calls fanned out against the resolved root) and it ships without focused tests — only existing registry mocks were extended.

Consider adding a focused case for the handler in apps/server/src/server.test.ts (the Layer.mock(ProviderRegistry.ProviderRegistry) seam there already supports overriding discoverSkillsForInstance / discoverSlashCommandsForInstance) covering draft-thread vs. worktree root resolution and the unknown-project error, plus a case in apps/server/src/provider/Layers/ProviderRegistry.test.ts for the empty-array fallback when the instance is not live or its driver omits discoverSkillsForCwd.

Posted via Macroscope — Effect Service Conventions

observeRpcEffect(
WS_METHODS.providersWorkspaceSkills,
Effect.gen(function* () {
// The project root is the base; a persisted thread's worktree
// overrides it. Keyed on the project rather than the thread so a
// DRAFT thread still resolves — it has no server-side row yet,
// and that is exactly when the skill picker matters most.
const project = yield* projectionSnapshotQuery
.getProjectShellById(input.projectId)
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: "Failed to load project for skill discovery",
cause,
}),
),
);
if (Option.isNone(project)) {
return yield* new OrchestrationGetSnapshotError({
message: `Unknown project ${input.projectId}`,
});
}
// An unknown thread id is not an error: a draft thread has no row.
const worktreePath = input.threadId
? yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe(
Effect.map((thread) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/ws.ts:1964

A request can name project A with a thread belonging to project B, and this handler then uses B's worktreePath for the project-scoped skill and slash-command discovery result. The threadId lookup must verify thread.value.projectId === input.projectId and ignore or reject mismatches.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 1964:

A request can name project A with a thread belonging to project B, and this handler then uses B's `worktreePath` for the project-scoped skill and slash-command discovery result. The `threadId` lookup must verify `thread.value.projectId === input.projectId` and ignore or reject mismatches.

Option.isSome(thread) ? thread.value.worktreePath : null,
),
Effect.orElseSucceed(() => null),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/ws.ts:1967

A projection failure for an existing thread is converted to null, so providersWorkspaceSkills silently scans project.value.workspaceRoot and returns incorrect or incomplete results instead of reporting the RPC error. Remove Effect.orElseSucceed(() => null) so only an actual Option.none() selects the project-root fallback.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 1967:

A projection failure for an existing thread is converted to `null`, so `providersWorkspaceSkills` silently scans `project.value.workspaceRoot` and returns incorrect or incomplete results instead of reporting the RPC error. Remove `Effect.orElseSucceed(() => null)` so only an actual `Option.none()` selects the project-root fallback.

)
: null;
const workspaceRoot = worktreePath ?? project.value.workspaceRoot;
// Run both against the same resolved root. The skills scan is
// filesystem-only; the slash-command probe may spawn the CLI but
// is cached per directory by the driver.
const [skills, slashCommands] = yield* Effect.all(
[
providerRegistry.discoverSkillsForInstance(input.instanceId, workspaceRoot),
providerRegistry.discoverSlashCommandsForInstance(
input.instanceId,
workspaceRoot,
),
],
{ concurrency: 2 },
);
return { workspaceRoot, skills, slashCommands };
}),
{ "rpc.aggregate": "provider" },
),
[WS_METHODS.assetsCreateUrl]: (input) =>
observeRpcEffect(
WS_METHODS.assetsCreateUrl,
Expand Down
37 changes: 36 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ import { environmentCatalog } from "../connection/catalog";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
import { projectEnvironment } from "../state/projects";
import { providerSkillsEnvironment } from "../state/providerSkills";
import { useEnvironmentQuery } from "../state/query";
import {
primaryServerAvailableEditorsAtom,
Expand Down Expand Up @@ -2720,6 +2721,38 @@ function ChatViewContent(props: ChatViewProps) {
const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
}, [activeProviderInstanceId, providerStatuses, selectedProvider]);
// Skills for the `$` picker, resolved against this thread's workspace root.
// `activeProviderStatus.skills` is machine-scoped: the server scans it once
// per provider instance against its own cwd, which a packaged desktop build
// sets to the user's home directory, so it reports user-scope skills only and
// is empty on a machine that keeps none there. Falling back to it keeps the
// picker working against a server that predates this RPC.
const workspaceCapabilitiesQuery = useEnvironmentQuery(
activeThread && activeProject && activeProviderInstanceId
? providerSkillsEnvironment.workspaceSkills({
environmentId: activeThread.environmentId,
input: {
projectId: activeProject.id,
instanceId: activeProviderInstanceId,
// Only a persisted thread has a server-side row; a draft has none,
// and the project root is the right answer for it anyway.
...(activeServerThread ? { threadId: activeServerThread.id } : {}),
},
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workspace query uses wrong instance

Medium Severity

The new workspace RPC is keyed on activeProviderInstanceId, which does not apply the composer’s enabled/available and locked-provider filters used by selectedInstanceId. When those diverge, the pickers can show another instance’s skills or slash commands while labeling them for the instance that will actually send the turn.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e69659. Configure here.

: null,
);
const activeSkills =
workspaceCapabilitiesQuery.data?.skills ??
activeProviderStatus?.skills ??
EMPTY_PROVIDER_SKILLS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty skills skip snapshot fallback

Medium Severity

activeSkills uses nullish coalescing on data?.skills, so a successful empty array from drivers without discoverSkillsForCwd replaces the snapshot. That contradicts the registry contract and the slash-command path, which only adopts workspace data when length > 0. Codex snapshot skills are cleared for the timeline once the RPC returns.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e69659. Configure here.

// Same story for `/`: the snapshot's slashCommands come from a probe run
// against the server's own cwd, so they are the CLI's built-ins only. The
// workspace-scoped result adds the project's own commands.
const activeSlashCommands =
workspaceCapabilitiesQuery.data?.slashCommands &&
workspaceCapabilitiesQuery.data.slashCommands.length > 0
? workspaceCapabilitiesQuery.data.slashCommands
: undefined;
const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
string | null
Expand Down Expand Up @@ -6534,7 +6567,7 @@ function ChatViewContent(props: ChatViewProps) {
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
workspaceRoot={activeWorkspaceRoot}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
skills={activeSkills}
anchorMessageId={timelineAnchorMessageId}
onAnchorReady={onTimelineAnchorReady}
contentInsetEndAdjustment={composerOverlayHeight}
Expand Down Expand Up @@ -6661,6 +6694,8 @@ function ChatViewContent(props: ChatViewProps) {
interactionMode={interactionMode}
lockedProvider={lockedProvider}
providerStatuses={providerStatuses as ServerProvider[]}
workspaceSkills={activeSkills}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activeSkills already falls back to activeProviderStatus?.skills, so what arrives as workspaceSkills here is often the machine-scoped snapshot of ChatView's resolved instance — and since composerSkills prefers any non-empty workspaceSkills over selectedProviderStatus?.skills, it overrides the composer's own selection.

The two resolutions can disagree: ChatView matches providerStatuses by id only, while ChatComposer additionally filters on enabled && isAvailable plus locked driver/continuation group. With a persisted-but-disabled selection, the workspace RPC returns [] (instance not live) and the $ picker then lists the disabled instance's snapshot skills instead of the instance that will run the turn.

Suggest passing the raw query result and letting ChatComposer own the fallback, the same way workspaceSlashCommands is passed (activeSkills can stay as-is for MessagesTimeline).

Suggested change
workspaceSkills={activeSkills}
workspaceSkills={workspaceCapabilitiesQuery.data?.skills}

Posted via Macroscope — UI Consistency

workspaceSlashCommands={activeSlashCommands}
activeProjectDefaultModelSelection={
activeProject?.defaultModelSelection
}
Expand Down
Loading
Loading