From 6e99ce67b845d44756852508a288634c18901abf Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:39:22 +0000 Subject: [PATCH 01/13] fix(composer): show project skills in menus Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../src/features/threads/ThreadComposer.tsx | 23 ++++- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 22 ++++- .../src/workspace/WorkspaceEntries.test.ts | 91 +++++++++++++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 79 +++++++++++++++- apps/server/src/ws.ts | 4 + apps/web/src/components/chat/ChatComposer.tsx | 52 +++++++---- .../client-runtime/src/providerSkills.test.ts | 57 ++++++++++++ packages/client-runtime/src/providerSkills.ts | 11 +++ .../src/state/projectCommands.ts | 6 ++ packages/contracts/src/rpc.ts | 15 ++- 11 files changed, 337 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f4b78f181e7d..459767e947f0 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -65,7 +65,10 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; +import { mergeProviderSkills } from "@t3tools/client-runtime/providerSkills"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; import { @@ -355,6 +358,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const projectSkillsQuery = useEnvironmentQuery( + selectedProviderStatus && props.projectCwd + ? projectEnvironment.listSkills({ + environmentId: props.environmentId, + input: { cwd: props.projectCwd, driver: selectedProviderStatus.driver }, + }) + : null, + ); + const selectedProviderSkills = useMemo( + () => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []), + [projectSkillsQuery.data, selectedProviderStatus?.skills], + ); // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ @@ -431,7 +446,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = selectedProviderSkills .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, @@ -445,7 +460,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = selectedProviderSkills.filter((s) => s.enabled); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); @@ -542,7 +557,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} selection={composerSelection} onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..d609309f5649 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, + [WS_METHODS.projectsListSkills]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index de3f5101f53e..bde16b3390d4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4748,7 +4748,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => + it.effect("routes websocket rpc project file and skill reads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -4758,6 +4758,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { path.join(workspaceDir, "src", "index.ts"), "export const answer = 42;\n", ); + yield* fs.makeDirectory(path.join(workspaceDir, ".agents", "skills", "review"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workspaceDir, ".agents", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review this project.\n---\n", + ); yield* buildAppUnderTest(); @@ -4770,6 +4777,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: workspaceDir, relativePath: "src/index.ts", }), + skills: client[WS_METHODS.projectsListSkills]({ + cwd: workspaceDir, + driver: ProviderDriverKind.make("codex"), + }), }), ), ); @@ -4781,6 +4792,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { byteLength: 26, truncated: false, }); + assert.deepEqual(response.skills, [ + { + name: "review", + description: "Review this project.", + path: path.join(workspaceDir, ".agents", "skills", "review", "SKILL.md"), + enabled: true, + scope: "project", + }, + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d47aaaec8264..faf85ce2abbb 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; +import { ProviderDriverKind } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; @@ -123,6 +124,96 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); + describe("listSkills", () => { + it.effect("discovers common project skills for every provider", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-agent-skills-" }); + yield* writeTextFile( + cwd, + ".agents/skills/unslop/SKILL.md", + "---\nname: unslop\ndescription: Remove AI writing patterns.\n---\n", + ); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + + for (const driver of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "futureProvider", + ].map((value) => ProviderDriverKind.make(value))) { + expect(yield* workspaceEntries.listSkills({ cwd, driver })).toEqual([ + { + name: "unslop", + description: "Remove AI writing patterns.", + path: path.join(cwd, ".agents", "skills", "unslop", "SKILL.md"), + enabled: true, + scope: "project", + }, + ]); + } + }), + ); + + it.effect("includes provider-native project skill directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-native-skills-" }); + const cases = [ + [ProviderDriverKind.make("claudeAgent"), ".claude"], + [ProviderDriverKind.make("cursor"), ".cursor"], + [ProviderDriverKind.make("grok"), ".grok"], + [ProviderDriverKind.make("opencode"), ".opencode"], + ] as const; + + for (const [driver, directory] of cases) { + yield* writeTextFile( + cwd, + `${directory}/skills/${driver}/SKILL.md`, + `---\nname: ${driver}\ndescription: ${driver} project skill.\n---\n`, + ); + } + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + for (const [driver] of cases) { + expect( + (yield* workspaceEntries.listSkills({ cwd, driver })).map((skill) => skill.name), + ).toContain(driver); + } + expect( + yield* workspaceEntries.listSkills({ cwd, driver: ProviderDriverKind.make("codex") }), + ).toEqual([]); + }), + ); + + it.effect("prefers provider-native skills over common project skills", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-skill-precedence-" }); + yield* writeTextFile( + cwd, + ".agents/skills/deploy/SKILL.md", + "---\nname: deploy\ndescription: Common deploy.\n---\n", + ); + yield* writeTextFile( + cwd, + ".cursor/skills/deploy/SKILL.md", + "---\nname: deploy\ndescription: Cursor deploy.\n---\n", + ); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const skills = yield* workspaceEntries.listSkills({ + cwd, + driver: ProviderDriverKind.make("cursor"), + }); + + expect(skills).toHaveLength(1); + expect(skills[0]?.description).toBe("Cursor deploy."); + }), + ); + }); + describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 28a30481b1b6..10596e7a070d 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -12,16 +12,19 @@ import * as Schema from "effect/Schema"; import type { FilesystemBrowseInput, FilesystemBrowseResult, + ProviderDriverKind, ProjectListEntriesInput, ProjectListEntriesResult, ProjectSearchContentsInput, ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ServerProviderSkill, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { parse as parseYamlDocument } from "yaml"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -84,6 +87,39 @@ export const WorkspaceEntriesError = Schema.Union([ ]); export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; +const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +const PROVIDER_PROJECT_SKILL_DIRECTORIES: Readonly>> = { + claudeAgent: [".claude"], + cursor: [".cursor"], + grok: [".grok"], + opencode: [".claude", ".opencode"], +}; + +function parseSkillFrontmatter(contents: string): { + readonly name?: string; + readonly description?: string; +} | null { + const match = SKILL_FRONTMATTER_PATTERN.exec(contents); + if (!match) return {}; + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + export class WorkspaceEntries extends Context.Service< WorkspaceEntries, { @@ -93,6 +129,10 @@ export class WorkspaceEntries extends Context.Service< readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect; + readonly listSkills: (input: { + readonly cwd: string; + readonly driver: ProviderDriverKind; + }) => Effect.Effect>; readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; @@ -288,7 +328,44 @@ export const make = Effect.gen(function* () { }, ); - return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); + const listSkills: WorkspaceEntries["Service"]["listSkills"] = Effect.fn( + "WorkspaceEntries.listSkills", + )(function* ({ cwd, driver }) { + const skillDirectories = [".agents", ...(PROVIDER_PROJECT_SKILL_DIRECTORIES[driver] ?? [])]; + const skillsByName = new Map(); + + for (const directory of skillDirectories) { + const skillsRoot = path.join(cwd, directory, "skills"); + const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(skillsRoot)).pipe( + Effect.orElseSucceed((): string[] => []), + ); + + for (const entry of entries.toSorted()) { + const skillPath = path.join(skillsRoot, entry, "SKILL.md"); + const contents = yield* Effect.tryPromise(() => NodeFSP.readFile(skillPath, "utf8")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (contents === undefined) continue; + + const frontmatter = parseSkillFrontmatter(contents); + if (frontmatter === null) continue; + const name = frontmatter.name ?? entry.trim(); + if (!name) continue; + + skillsByName.set(name, { + name, + path: skillPath, + enabled: true, + scope: "project", + ...(frontmatter.description ? { description: frontmatter.description } : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); + }); + + return WorkspaceEntries.of({ browse, list, listSkills, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..41e03dfb6b44 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1884,6 +1884,10 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListSkills]: (input) => + observeRpcEffect(WS_METHODS.projectsListSkills, workspaceEntries.listSkills(input), { + "rpc.aggregate": "workspace", + }), [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect( WS_METHODS.projectsReadFile, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f06a9658225f..1f6b5bd565ec 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -252,10 +252,15 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + formatProviderSkillDisplayName, + mergeProviderSkills, +} from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; const runtimeModeConfig: Record< RuntimeMode, @@ -886,6 +891,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const projectSkillsQuery = useEnvironmentQuery( + gitCwd + ? projectEnvironment.listSkills({ + environmentId, + input: { cwd: gitCwd, driver: selectedProvider }, + }) + : null, + ); + const selectedProviderSkills = useMemo( + () => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []), + [projectSkillsQuery.data, selectedProviderStatus?.skills], + ); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -1118,7 +1135,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), ); const query = composerTrigger.query.trim().toLowerCase(); - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = selectedProviderSkills .filter((skill) => skill.enabled) .map((skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, @@ -1139,25 +1156,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; }, [ composerTrigger, planModeUiEnabled, selectedProvider, + selectedProviderSkills, selectedProviderStatus, workspaceEntries.entries, ]); @@ -1224,7 +1240,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && + projectSkillsQuery.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -3227,7 +3245,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index fa6b460b990c..0f86c465577a 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { formatProviderSkillDisplayName, + mergeProviderSkills, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; @@ -75,3 +76,59 @@ describe("resolveProviderSkillSourceKind", () => { ).toBe("other"); }); }); + +describe("mergeProviderSkills", () => { + it("keeps global skills and lets project skills override matching names", () => { + expect( + mergeProviderSkills( + [ + { + name: "global-only", + path: "/home/user/.agents/skills/global-only/SKILL.md", + enabled: true, + scope: "user", + }, + { + name: "deploy", + path: "/home/user/.agents/skills/deploy/SKILL.md", + enabled: true, + scope: "user", + }, + ], + [ + { + name: "deploy", + path: "/workspace/.agents/skills/deploy/SKILL.md", + enabled: true, + scope: "project", + }, + { + name: "project-only", + path: "/workspace/.agents/skills/project-only/SKILL.md", + enabled: true, + scope: "project", + }, + ], + ), + ).toEqual([ + { + name: "deploy", + path: "/workspace/.agents/skills/deploy/SKILL.md", + enabled: true, + scope: "project", + }, + { + name: "global-only", + path: "/home/user/.agents/skills/global-only/SKILL.md", + enabled: true, + scope: "user", + }, + { + name: "project-only", + path: "/workspace/.agents/skills/project-only/SKILL.md", + enabled: true, + scope: "project", + }, + ]); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index d24776d8525d..c42fd170ee6c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -54,3 +54,14 @@ export function resolveProviderSkillSourceKind( return "other"; } } + +export function mergeProviderSkills( + providerSkills: ReadonlyArray, + projectSkills: ReadonlyArray, +): ServerProviderSkill[] { + const skillsByName = new Map(providerSkills.map((skill) => [skill.name, skill])); + for (const skill of projectSkills) { + skillsByName.set(skill.name, skill); + } + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..4b4654b48df8 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -66,6 +66,12 @@ export function createProjectEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), + listSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:list-skills", + tag: WS_METHODS.projectsListSkills, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..90e3c4f36b7c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,6 +8,7 @@ import { AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BackgroundPolicySnapshot, ClientActivityReportInput, @@ -66,7 +67,7 @@ import { OrchestrationRpcSchemas, OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; -import { ProviderInstanceId } from "./providerInstance.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -154,6 +155,7 @@ import { import { ServerConfigStreamEvent, ServerConfig, + ServerProviderSkill, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -199,6 +201,7 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", + projectsListSkills: "projects.listSkills", projectsReadFile: "projects.readFile", projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", @@ -636,6 +639,15 @@ export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsListSkillsRpc = Rpc.make(WS_METHODS.projectsListSkills, { + payload: Schema.Struct({ + cwd: TrimmedNonEmptyString, + driver: ProviderDriverKind, + }), + success: Schema.Array(ServerProviderSkill), + error: EnvironmentAuthorizationError, +}); + export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, @@ -1027,6 +1039,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, + WsProjectsListSkillsRpc, WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, From 8cf3736d1787f1027cf24139147ea52647184797 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:47:22 +0000 Subject: [PATCH 02/13] fix(composer): keep skill discovery in thread scope Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .macroscope/check-run-agents/ui-consistency.md | 4 ++++ .../src/features/threads/ThreadComposer.tsx | 8 ++++---- .../src/features/threads/ThreadDetailScreen.tsx | 3 +-- .../src/features/threads/ThreadRouteScreen.tsx | 1 - .../chat/ComposerCommandMenu.test.tsx | 17 +++++++++++++++++ .../src/components/chat/ComposerCommandMenu.tsx | 6 +++--- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 8ec720742759..96e082c4a6ff 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -12,6 +12,8 @@ include: - "apps/web/src/**/*.ts" - "apps/web/src/**/*.tsx" - "apps/web/src/**/*.css" + - "apps/mobile/src/**/*.ts" + - "apps/mobile/src/**/*.tsx" conclusion: failure showToolCalls: true --- @@ -69,6 +71,8 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put ## Change discipline +- Keep async loading and empty-state copy aligned with the active trigger and data source. When a shared menu adds a trigger to a pending condition, trace its loading-copy branch so skill discovery cannot fall through to file-search text. +- For workspace-scoped composer data on web or mobile, trace the cwd prop to its source. Existing threads must prefer the active thread or worktree cwd over the parent project's workspace root so branch-local files, skills, and commands cannot come from the wrong checkout. - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. - Prefer the smallest durable contract over a component-specific workaround or a broad abstraction with one consumer. - Preserve intentional exceptions and comments that explain browser, virtualizer, theme, or Electron constraints. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 459767e947f0..5dc23010091f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -111,7 +111,7 @@ export interface ThreadComposerProps { readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; - readonly projectCwd: string | null; + readonly threadCwd: string | null; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; @@ -359,10 +359,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const projectSkillsQuery = useEnvironmentQuery( - selectedProviderStatus && props.projectCwd + selectedProviderStatus && props.threadCwd ? projectEnvironment.listSkills({ environmentId: props.environmentId, - input: { cwd: props.projectCwd, driver: selectedProviderStatus.driver }, + input: { cwd: props.threadCwd, driver: selectedProviderStatus.driver }, }) : null, ); @@ -400,7 +400,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [composerSelection, props.draftMessage]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, - cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, + cwd: composerTrigger?.kind === "path" ? props.threadCwd : null, query: composerTrigger?.kind === "path" ? composerTrigger.query : null, }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2c6860199722..9460afb13fd5 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -103,7 +103,6 @@ export interface ThreadDetailScreenProps { /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; readonly environmentId: EnvironmentId; - readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; @@ -753,7 +752,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} environmentId={props.environmentId} - projectCwd={props.projectWorkspaceRoot} + threadCwd={props.threadCwd} bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} onPickDraftImages={props.onPickDraftImages} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index cad1cab8e602..0ea1bd01fa10 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -786,7 +786,6 @@ function ThreadRouteContent( threadSyncStatus={selectedThreadDetailState.status} loadEarlier={loadEarlierTurns} environmentId={selectedThread.environmentId} - projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} selectedThreadQueueCount={composer.selectedThreadQueueCount} layoutVariant={layout.variant} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx index 350a09f9abb6..e67f3ce2a6b6 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -24,6 +24,23 @@ describe("ComposerCommandMenu", () => { expect(markup).not.toContain("dropdown-glass"); }); + it("describes pending slash skill discovery without file-search copy", () => { + const markup = renderToStaticMarkup( + {}} + onSelect={() => {}} + />, + ); + + expect(markup).toContain("Searching workspace skills..."); + expect(markup).not.toContain("Searching workspace files..."); + }); + it("renders commands without a category heading or invented icons", () => { const markup = renderToStaticMarkup(

{props.isLoading - ? props.triggerKind === "skill" - ? "Searching workspace skills..." - : "Searching workspace files..." + ? props.triggerKind === "path" + ? "Searching workspace files..." + : "Searching workspace skills..." : (props.emptyStateText ?? (props.triggerKind === "skill" ? "No skills found. Try / to browse provider commands." From 1ced740eab1f2e567b9e224c728d7b407925d5d9 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:49:11 +0000 Subject: [PATCH 03/13] fix(mobile): show project skill loading state Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .macroscope/check-run-agents/ui-consistency.md | 2 +- apps/mobile/src/features/threads/ThreadComposer.tsx | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 96e082c4a6ff..7745dc6d55c2 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -71,7 +71,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put ## Change discipline -- Keep async loading and empty-state copy aligned with the active trigger and data source. When a shared menu adds a trigger to a pending condition, trace its loading-copy branch so skill discovery cannot fall through to file-search text. +- Keep async loading and empty-state copy aligned with the active trigger and data source. When a shared menu adds an async query, trace the popover mount condition, the `isLoading` source, and its copy branch so pending skill discovery stays visible and cannot fall through to file-search text. - For workspace-scoped composer data on web or mobile, trace the cwd prop to its source. Existing threads must prefer the active thread or worktree cwd over the parent project's workspace root so branch-local files, skills, and commands cannot come from the wrong checkout. - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. - Prefer the smallest durable contract over a component-specific workaround or a broad abstraction with one consumer. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 5dc23010091f..b95bedea65b4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -748,12 +748,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > - {composerTrigger && composerMenuItems.length > 0 ? ( + {composerTrigger && + (composerMenuItems.length > 0 || + ((composerTrigger.kind === "skill" || composerTrigger.kind === "slash-command") && + projectSkillsQuery.isPending)) ? ( From 8ca5b7bc28fd0b7148b70a188e19ec76425eea6f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:39:21 +0000 Subject: [PATCH 04/13] fix(provider): scope capability snapshots to workspaces Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../effect-service-conventions.md | 1 + .../check-run-agents/ui-consistency.md | 4 - .../src/features/threads/ThreadComposer.tsx | 43 ++++----- .../features/threads/ThreadDetailScreen.tsx | 2 + .../features/threads/ThreadRouteScreen.tsx | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 - .../Layers/ProviderCommandReactor.test.ts | 11 ++- .../Layers/ProviderCommandReactor.ts | 33 +++++-- .../src/provider/Drivers/ClaudeDriver.ts | 16 ++++ .../src/provider/Drivers/CodexDriver.ts | 23 ++++- .../src/provider/Drivers/CursorDriver.ts | 17 ++-- .../server/src/provider/Drivers/GrokDriver.ts | 13 ++- .../src/provider/Drivers/OpenCodeDriver.ts | 12 ++- .../src/provider/Layers/CodexProvider.ts | 3 +- .../src/provider/Layers/CursorProvider.ts | 16 +++- .../src/provider/Layers/GrokProvider.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 54 +++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 70 +++++++++++++- apps/server/src/provider/ProviderDriver.ts | 2 + .../src/provider/Services/ProviderRegistry.ts | 5 + .../providerMaintenanceRunner.test.ts | 1 + .../testUtils/providerRegistryMock.ts | 9 +- apps/server/src/server.test.ts | 22 +---- .../src/workspace/WorkspaceEntries.test.ts | 91 ------------------- apps/server/src/workspace/WorkspaceEntries.ts | 79 +--------------- apps/server/src/ws.ts | 4 - apps/web/src/components/chat/ChatComposer.tsx | 46 ++++------ .../chat/ComposerCommandMenu.test.tsx | 17 ---- .../components/chat/ComposerCommandMenu.tsx | 6 +- .../client-runtime/src/providerSkills.test.ts | 90 ++++++++---------- packages/client-runtime/src/providerSkills.ts | 31 +++++-- .../src/state/projectCommands.ts | 6 -- packages/contracts/src/rpc.ts | 15 +-- packages/contracts/src/server.ts | 9 ++ 34 files changed, 359 insertions(+), 400 deletions(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index b76d56d45dbc..730535803c31 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -81,6 +81,7 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline +- Keep provider capability discovery provider-owned. Project/worktree catalogs must be cached by provider instance plus cwd, preserve the machine snapshot as the pre-session fallback, and never overwrite one project's menus with another cwd's snapshot. Flag generic filesystem reimplementations of provider discovery when the driver can probe from the active session cwd. - Preserve useful comments, invariants, and specification documentation while moving code. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. - If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 7745dc6d55c2..8ec720742759 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -12,8 +12,6 @@ include: - "apps/web/src/**/*.ts" - "apps/web/src/**/*.tsx" - "apps/web/src/**/*.css" - - "apps/mobile/src/**/*.ts" - - "apps/mobile/src/**/*.tsx" conclusion: failure showToolCalls: true --- @@ -71,8 +69,6 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put ## Change discipline -- Keep async loading and empty-state copy aligned with the active trigger and data source. When a shared menu adds an async query, trace the popover mount condition, the `isLoading` source, and its copy branch so pending skill discovery stays visible and cannot fall through to file-search text. -- For workspace-scoped composer data on web or mobile, trace the cwd prop to its source. Existing threads must prefer the active thread or worktree cwd over the parent project's workspace root so branch-local files, skills, and commands cannot come from the wrong checkout. - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. - Prefer the smallest durable contract over a component-specific workaround or a broad abstraction with one consumer. - Preserve intentional exceptions and comments that explain browser, virtualizer, theme, or Electron constraints. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index b95bedea65b4..36e9eba1e935 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -64,11 +64,12 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, +} from "@t3tools/client-runtime/providerSkills"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; -import { mergeProviderSkills } from "@t3tools/client-runtime/providerSkills"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; -import { projectEnvironment } from "../../state/projects"; -import { useEnvironmentQuery } from "../../state/query"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; import { @@ -111,6 +112,7 @@ export interface ThreadComposerProps { readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; + readonly projectCwd: string | null; readonly threadCwd: string | null; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; @@ -358,18 +360,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); - const projectSkillsQuery = useEnvironmentQuery( - selectedProviderStatus && props.threadCwd - ? projectEnvironment.listSkills({ - environmentId: props.environmentId, - input: { cwd: props.threadCwd, driver: selectedProviderStatus.driver }, - }) - : null, - ); - const selectedProviderSkills = useMemo( - () => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []), - [projectSkillsQuery.data, selectedProviderStatus?.skills], - ); + const selectedProviderSkills = selectedProviderStatus + ? resolveProviderSkillsForCwd(selectedProviderStatus, props.threadCwd) + : []; + const selectedProviderSlashCommands = selectedProviderStatus + ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, props.threadCwd) + : []; // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ @@ -400,7 +396,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [composerSelection, props.draftMessage]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, - cwd: composerTrigger?.kind === "path" ? props.threadCwd : null, + cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, query: composerTrigger?.kind === "path" ? composerTrigger.query : null, }); @@ -435,7 +431,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); const providerCommands: ComposerCommandItem[] = []; - for (const cmd of selectedProviderStatus?.slashCommands ?? []) { + for (const cmd of selectedProviderSlashCommands) { if (!cmd.name.toLowerCase().includes(q)) continue; providerCommands.push({ id: `pcmd:${cmd.name}`, @@ -557,7 +553,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderSlashCommands]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -748,19 +744,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > - {composerTrigger && - (composerMenuItems.length > 0 || - ((composerTrigger.kind === "skill" || composerTrigger.kind === "slash-command") && - projectSkillsQuery.isPending)) ? ( + {composerTrigger && composerMenuItems.length > 0 ? ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9460afb13fd5..4c2dacc1049d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -103,6 +103,7 @@ export interface ThreadDetailScreenProps { /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; readonly environmentId: EnvironmentId; + readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; @@ -752,6 +753,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} environmentId={props.environmentId} + projectCwd={props.projectWorkspaceRoot} threadCwd={props.threadCwd} bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0ea1bd01fa10..cad1cab8e602 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -786,6 +786,7 @@ function ThreadRouteContent( threadSyncStatus={selectedThreadDetailState.status} loadEarlier={loadEarlierTurns} environmentId={selectedThread.environmentId} + projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} selectedThreadQueueCount={composer.selectedThreadQueueCount} layoutVariant={layout.variant} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d609309f5649..6b35f0d54e18 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -77,7 +77,6 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, - [WS_METHODS.projectsListSkills]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..04904d0f3668 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -306,6 +306,7 @@ describe("ProviderCommandReactor", () => { : {}), }, ]; + const refreshWorkspaceSnapshot = vi.fn(() => Effect.succeed(providerSnapshots as never)); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -391,7 +392,9 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(reactorOrchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), + Layer.provideMerge( + makeProviderRegistryLayer(providerSnapshots as never, { refreshWorkspaceSnapshot }), + ), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, @@ -500,6 +503,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, refreshStatus, + refreshWorkspaceSnapshot, generateBranchName, generateThreadTitle, runtimeSessions, @@ -535,6 +539,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(() => harness.refreshWorkspaceSnapshot.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", @@ -544,6 +549,10 @@ describe("ProviderCommandReactor", () => { }, runtimeMode: "approval-required", }); + expect(harness.refreshWorkspaceSnapshot).toHaveBeenCalledWith({ + instanceId: ProviderInstanceId.make("codex"), + cwd: "/tmp/provider-project", + }); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..d03cfb8b9a82 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -609,16 +609,29 @@ const make = Effect.gen(function* () { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; }) => - providerService.startSession(threadId, { - threadId, - ...(preferredProvider ? { provider: preferredProvider } : {}), - providerInstanceId: desiredInstanceId, - ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), - modelSelection: desiredModelSelection, - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: desiredRuntimeMode, - }); + providerService + .startSession(threadId, { + threadId, + ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), + modelSelection: desiredModelSelection, + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, + }) + .pipe( + Effect.tap(() => + effectiveCwd + ? providerRegistry + .refreshWorkspaceSnapshot({ + instanceId: desiredInstanceId, + cwd: effectiveCwd, + }) + .pipe(Effect.forkDetach) + : Effect.void, + ), + ); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..a4881f841639 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -174,6 +174,21 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); + const snapshotForCwd = (workspaceCwd: string) => + checkClaudeProviderStatus( + effectiveConfig, + () => + probeClaudeCapabilities(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(Path.Path, path), + ), + processEnv, + workspaceCwd, + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ @@ -214,6 +229,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..885a3af2fd37 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -166,10 +166,24 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const checkProviderForCwd = (cwd?: string) => + checkCodexProviderStatus(effectiveConfig, undefined, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const checkProvider = checkProviderForCwd(); + const snapshotForCwd = (cwd: string) => + checkProviderForCwd(cwd).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Codex snapshot for '${cwd}': ${cause.message}`, + cause, + }), + ), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, @@ -206,6 +220,7 @@ export const CodexDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb1..e5f5e747b954 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -132,13 +132,15 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + const checkProviderForCwd = (cwd?: string) => + checkCursorProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const checkProvider = checkProviderForCwd(); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ @@ -181,6 +183,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd: checkProviderForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f11013161..17d3fad137e3 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -113,11 +113,13 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const checkProviderForCwd = (cwd?: string) => + checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const checkProvider = checkProviderForCwd(); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ @@ -156,6 +158,7 @@ export const GrokDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd: checkProviderForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f8116..ec7866204235 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -143,11 +143,12 @@ export const OpenCodeDriver: ProviderDriver }); const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkOpenCodeProviderStatus( - effectiveConfig, - serverConfig.cwd, - processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + const checkProviderForCwd = (cwd: string) => + checkOpenCodeProviderStatus(effectiveConfig, cwd, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + ); + const checkProvider = checkProviderForCwd(serverConfig.cwd); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( @@ -187,6 +188,7 @@ export const OpenCodeDriver: ProviderDriver accentColor, enabled, snapshot, + snapshotForCwd: checkProviderForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1d58ca6b18d9..77c050fcc9c0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -521,6 +521,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -551,7 +552,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment: resolvedEnvironment, }).pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5c..7b45b1e6544d 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -403,6 +403,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + cwd: string = process.cwd(), ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -414,10 +415,10 @@ const makeCursorAcpProbeRuntime = ( ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", ], - cwd: process.cwd(), + cwd, ...(environment ? { env: environment } : {}), }, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -432,8 +433,9 @@ const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => - makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( + makeCursorAcpProbeRuntime(cursorSettings, environment, cwd).pipe( Effect.flatMap(useRuntime), Effect.scoped, ); @@ -553,6 +555,7 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => withCursorAcpProbeRuntime( cursorSettings, @@ -564,12 +567,14 @@ const discoverCursorModelsViaListAvailableModels = ( return buildCursorDiscoveredModelsFromAvailableModelsResponse(decoded); }), environment, + cwd, ); export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, -) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); + cwd?: string, +) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment, cwd); export function getCursorFallbackModels( cursorSettings: Pick, @@ -987,6 +992,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, never, @@ -1084,7 +1090,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment).pipe( + discoverCursorModelsViaAcp(cursorSettings, environment, cwd).pipe( Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), ), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..15aaf2796c77 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -126,6 +126,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -133,7 +134,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -161,6 +162,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, never, @@ -251,7 +253,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment, cwd).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..1a3e860382c9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -41,6 +41,7 @@ import { haveProvidersChanged, mergeProviderSnapshot, ProviderRegistryLive, + upsertProviderWorkspaceSnapshot, } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; @@ -399,6 +400,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("probes Codex snapshots from the requested workspace cwd", () => + Effect.gen(function* () { + let observedCwd: string | undefined; + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + (input) => { + observedCwd = input.cwd; + return Effect.succeed(makeCodexProbeSnapshot()); + }, + undefined, + "/workspace/project-a", + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedCwd, "/workspace/project-a"); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -525,6 +544,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { + it("keeps the machine snapshot while caching a provider-probed cwd snapshot", () => { + const machine = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const scoped = { + ...machine, + checkedAt: "2026-01-01T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } satisfies ServerProvider; + + const next = upsertProviderWorkspaceSnapshot(machine, "/workspace", scoped); + + assert.deepEqual(next.skills, machine.skills); + assert.deepEqual(next.slashCommands, machine.slashCommands); + assert.deepEqual(next.workspaceSnapshots, [ + { + cwd: "/workspace", + checkedAt: scoped.checkedAt, + slashCommands: scoped.slashCommands, + skills: scoped.skills, + }, + ]); + }); + it("treats equal provider snapshots as unchanged", () => { const providers = [ { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..57a7d9b35f51 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,6 +78,26 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; + +export function upsertProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string, + scopedSnapshot: ServerProvider, +): ServerProvider { + const workspaceSnapshot = { + cwd, + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + } satisfies NonNullable[number]; + const workspaceSnapshots = [ + ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), + workspaceSnapshot, + ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER); + return { ...provider, workspaceSnapshots }; +} + const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver !== ProviderDriverKind.make("opencode")) { return true; @@ -132,6 +152,11 @@ export const mergeProviderSnapshot = ( : { ...nextProvider, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), }; export const mergeProviderSnapshots = ( @@ -321,7 +346,8 @@ export const ProviderRegistryLive = Layer.effect( cacheDir: config.providerStatusCacheDir, instanceId: key, }).pipe(Effect.provideService(Path.Path, path)); - yield* writeProviderStatusCache({ filePath, provider }).pipe( + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; + yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.tapError(Effect.logError), @@ -704,12 +730,54 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const providers = yield* Ref.get(providersRef); + const machineSnapshot = providers.find( + (provider) => provider.instanceId === input.instanceId, + ); + if ( + machineSnapshot === undefined || + machineSnapshot.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ) { + return providers; + } + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (instance?.snapshotForCwd === undefined) { + return providers; + } + const scopedSnapshot = yield* instance.snapshotForCwd(input.cwd); + if (scopedSnapshot.status === "error") { + return yield* Ref.get(providersRef); + } + const source = buildSnapshotSource(instance); + const correlatedSnapshot = yield* correlateSnapshotWithSource(source, scopedSnapshot); + const latestProviders = yield* Ref.get(providersRef); + const latestMachineSnapshot = latestProviders.find( + (provider) => provider.instanceId === input.instanceId, + ); + if ( + latestMachineSnapshot === undefined || + latestMachineSnapshot.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ) { + return latestProviders; + } + return yield* upsertProviders( + [upsertProviderWorkspaceSnapshot(latestMachineSnapshot, input.cwd, correlatedSnapshot)], + { persist: false, replace: true }, + ); + }); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshWorkspaceSnapshot: (input) => + refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4..bdb3020ed42f 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProvider, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -69,6 +70,7 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; + readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; } diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b303381..a1ea0c083ac2 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -48,6 +48,11 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + readonly refreshWorkspaceSnapshot: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 641c9b52e56c..c45f1f7406cb 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b059009..4ca947d607cf 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -7,15 +7,20 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte export const makeProviderRegistryMock = ( providers: ReadonlyArray = [], + overrides: Partial = {}, ): ProviderRegistryShape => ({ getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), streamChanges: Stream.empty, + ...overrides, }); -export const makeProviderRegistryLayer = (providers: ReadonlyArray = []) => - Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers)); +export const makeProviderRegistryLayer = ( + providers: ReadonlyArray = [], + overrides: Partial = {}, +) => Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers, overrides)); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bde16b3390d4..de3f5101f53e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4748,7 +4748,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("routes websocket rpc project file and skill reads", () => + it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -4758,13 +4758,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { path.join(workspaceDir, "src", "index.ts"), "export const answer = 42;\n", ); - yield* fs.makeDirectory(path.join(workspaceDir, ".agents", "skills", "review"), { - recursive: true, - }); - yield* fs.writeFileString( - path.join(workspaceDir, ".agents", "skills", "review", "SKILL.md"), - "---\nname: review\ndescription: Review this project.\n---\n", - ); yield* buildAppUnderTest(); @@ -4777,10 +4770,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: workspaceDir, relativePath: "src/index.ts", }), - skills: client[WS_METHODS.projectsListSkills]({ - cwd: workspaceDir, - driver: ProviderDriverKind.make("codex"), - }), }), ), ); @@ -4792,15 +4781,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { byteLength: 26, truncated: false, }); - assert.deepEqual(response.skills, [ - { - name: "review", - description: "Review this project.", - path: path.join(workspaceDir, ".agents", "skills", "review", "SKILL.md"), - enabled: true, - scope: "project", - }, - ]); }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index faf85ce2abbb..d47aaaec8264 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -11,7 +11,6 @@ import * as PlatformError from "effect/PlatformError"; import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; -import { ProviderDriverKind } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; @@ -124,96 +123,6 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); - describe("listSkills", () => { - it.effect("discovers common project skills for every provider", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-agent-skills-" }); - yield* writeTextFile( - cwd, - ".agents/skills/unslop/SKILL.md", - "---\nname: unslop\ndescription: Remove AI writing patterns.\n---\n", - ); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const path = yield* Path.Path; - - for (const driver of [ - "codex", - "claudeAgent", - "cursor", - "grok", - "opencode", - "futureProvider", - ].map((value) => ProviderDriverKind.make(value))) { - expect(yield* workspaceEntries.listSkills({ cwd, driver })).toEqual([ - { - name: "unslop", - description: "Remove AI writing patterns.", - path: path.join(cwd, ".agents", "skills", "unslop", "SKILL.md"), - enabled: true, - scope: "project", - }, - ]); - } - }), - ); - - it.effect("includes provider-native project skill directories", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-native-skills-" }); - const cases = [ - [ProviderDriverKind.make("claudeAgent"), ".claude"], - [ProviderDriverKind.make("cursor"), ".cursor"], - [ProviderDriverKind.make("grok"), ".grok"], - [ProviderDriverKind.make("opencode"), ".opencode"], - ] as const; - - for (const [driver, directory] of cases) { - yield* writeTextFile( - cwd, - `${directory}/skills/${driver}/SKILL.md`, - `---\nname: ${driver}\ndescription: ${driver} project skill.\n---\n`, - ); - } - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - for (const [driver] of cases) { - expect( - (yield* workspaceEntries.listSkills({ cwd, driver })).map((skill) => skill.name), - ).toContain(driver); - } - expect( - yield* workspaceEntries.listSkills({ cwd, driver: ProviderDriverKind.make("codex") }), - ).toEqual([]); - }), - ); - - it.effect("prefers provider-native skills over common project skills", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-skill-precedence-" }); - yield* writeTextFile( - cwd, - ".agents/skills/deploy/SKILL.md", - "---\nname: deploy\ndescription: Common deploy.\n---\n", - ); - yield* writeTextFile( - cwd, - ".cursor/skills/deploy/SKILL.md", - "---\nname: deploy\ndescription: Cursor deploy.\n---\n", - ); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const skills = yield* workspaceEntries.listSkills({ - cwd, - driver: ProviderDriverKind.make("cursor"), - }); - - expect(skills).toHaveLength(1); - expect(skills[0]?.description).toBe("Cursor deploy."); - }), - ); - }); - describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 10596e7a070d..28a30481b1b6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -12,19 +12,16 @@ import * as Schema from "effect/Schema"; import type { FilesystemBrowseInput, FilesystemBrowseResult, - ProviderDriverKind, ProjectListEntriesInput, ProjectListEntriesResult, ProjectSearchContentsInput, ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, - ServerProviderSkill, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; -import { parse as parseYamlDocument } from "yaml"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -87,39 +84,6 @@ export const WorkspaceEntriesError = Schema.Union([ ]); export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; -const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; - -const PROVIDER_PROJECT_SKILL_DIRECTORIES: Readonly>> = { - claudeAgent: [".claude"], - cursor: [".cursor"], - grok: [".grok"], - opencode: [".claude", ".opencode"], -}; - -function parseSkillFrontmatter(contents: string): { - readonly name?: string; - readonly description?: string; -} | null { - const match = SKILL_FRONTMATTER_PATTERN.exec(contents); - if (!match) return {}; - - let parsed: unknown; - try { - parsed = parseYamlDocument(match[1] ?? ""); - } catch { - return null; - } - if (typeof parsed !== "object" || parsed === null) return null; - - const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const description = typeof record.description === "string" ? record.description.trim() : ""; - return { - ...(name ? { name } : {}), - ...(description ? { description } : {}), - }; -} - export class WorkspaceEntries extends Context.Service< WorkspaceEntries, { @@ -129,10 +93,6 @@ export class WorkspaceEntries extends Context.Service< readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect; - readonly listSkills: (input: { - readonly cwd: string; - readonly driver: ProviderDriverKind; - }) => Effect.Effect>; readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; @@ -328,44 +288,7 @@ export const make = Effect.gen(function* () { }, ); - const listSkills: WorkspaceEntries["Service"]["listSkills"] = Effect.fn( - "WorkspaceEntries.listSkills", - )(function* ({ cwd, driver }) { - const skillDirectories = [".agents", ...(PROVIDER_PROJECT_SKILL_DIRECTORIES[driver] ?? [])]; - const skillsByName = new Map(); - - for (const directory of skillDirectories) { - const skillsRoot = path.join(cwd, directory, "skills"); - const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(skillsRoot)).pipe( - Effect.orElseSucceed((): string[] => []), - ); - - for (const entry of entries.toSorted()) { - const skillPath = path.join(skillsRoot, entry, "SKILL.md"); - const contents = yield* Effect.tryPromise(() => NodeFSP.readFile(skillPath, "utf8")).pipe( - Effect.orElseSucceed(() => undefined), - ); - if (contents === undefined) continue; - - const frontmatter = parseSkillFrontmatter(contents); - if (frontmatter === null) continue; - const name = frontmatter.name ?? entry.trim(); - if (!name) continue; - - skillsByName.set(name, { - name, - path: skillPath, - enabled: true, - scope: "project", - ...(frontmatter.description ? { description: frontmatter.description } : {}), - }); - } - } - - return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); - }); - - return WorkspaceEntries.of({ browse, list, listSkills, refresh, search, searchContents }); + return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 41e03dfb6b44..c3caea225704 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1884,10 +1884,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.projectsListSkills]: (input) => - observeRpcEffect(WS_METHODS.projectsListSkills, workspaceEntries.listSkills(input), { - "rpc.aggregate": "workspace", - }), [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect( WS_METHODS.projectsReadFile, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1f6b5bd565ec..9a3fb8143707 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -254,13 +254,12 @@ import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName, - mergeProviderSkills, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; -import { projectEnvironment } from "../../state/projects"; -import { useEnvironmentQuery } from "../../state/query"; const runtimeModeConfig: Record< RuntimeMode, @@ -891,18 +890,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); - const projectSkillsQuery = useEnvironmentQuery( - gitCwd - ? projectEnvironment.listSkills({ - environmentId, - input: { cwd: gitCwd, driver: selectedProvider }, - }) - : null, - ); - const selectedProviderSkills = useMemo( - () => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []), - [projectSkillsQuery.data, selectedProviderStatus?.skills], - ); + const selectedProviderSkills = selectedProviderStatus + ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) + : []; + const selectedProviderSlashCommands = selectedProviderStatus + ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) + : []; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -1124,16 +1117,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ] as const) : []), ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( - (command) => ({ - id: `provider-slash-command:${selectedProvider}:${command.name}`, - type: "provider-slash-command" as const, - provider: selectedProvider, - command, - label: `/${command.name}`, - description: command.description ?? command.input?.hint ?? "Run provider command", - }), - ); + const providerSlashCommandItems = selectedProviderSlashCommands.map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); const query = composerTrigger.query.trim().toLowerCase(); const skillItems = selectedProviderSkills .filter((skill) => skill.enabled) @@ -1174,6 +1165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeUiEnabled, selectedProvider, selectedProviderSkills, + selectedProviderSlashCommands, selectedProviderStatus, workspaceEntries.entries, ]); @@ -1240,9 +1232,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || - ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && - projectSkillsQuery.isPending); + composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx index e67f3ce2a6b6..350a09f9abb6 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -24,23 +24,6 @@ describe("ComposerCommandMenu", () => { expect(markup).not.toContain("dropdown-glass"); }); - it("describes pending slash skill discovery without file-search copy", () => { - const markup = renderToStaticMarkup( - {}} - onSelect={() => {}} - />, - ); - - expect(markup).toContain("Searching workspace skills..."); - expect(markup).not.toContain("Searching workspace files..."); - }); - it("renders commands without a category heading or invented icons", () => { const markup = renderToStaticMarkup(

{props.isLoading - ? props.triggerKind === "path" - ? "Searching workspace files..." - : "Searching workspace skills..." + ? props.triggerKind === "skill" + ? "Searching workspace skills..." + : "Searching workspace files..." : (props.emptyStateText ?? (props.triggerKind === "skill" ? "No skills found. Try / to browse provider commands." diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 0f86c465577a..9bc91bcf773a 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,11 +1,35 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { formatProviderSkillDisplayName, - mergeProviderSkills, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; +const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + workspaceSnapshots: [ + { + cwd: "/workspace/project-a", + checkedAt: "2026-01-01T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }], + }, + ], +} satisfies ServerProvider; + describe("formatProviderSkillDisplayName", () => { it("prefers the provider display name", () => { expect( @@ -77,58 +101,18 @@ describe("resolveProviderSkillSourceKind", () => { }); }); -describe("mergeProviderSkills", () => { - it("keeps global skills and lets project skills override matching names", () => { - expect( - mergeProviderSkills( - [ - { - name: "global-only", - path: "/home/user/.agents/skills/global-only/SKILL.md", - enabled: true, - scope: "user", - }, - { - name: "deploy", - path: "/home/user/.agents/skills/deploy/SKILL.md", - enabled: true, - scope: "user", - }, - ], - [ - { - name: "deploy", - path: "/workspace/.agents/skills/deploy/SKILL.md", - enabled: true, - scope: "project", - }, - { - name: "project-only", - path: "/workspace/.agents/skills/project-only/SKILL.md", - enabled: true, - scope: "project", - }, - ], - ), - ).toEqual([ - { - name: "deploy", - path: "/workspace/.agents/skills/deploy/SKILL.md", - enabled: true, - scope: "project", - }, - { - name: "global-only", - path: "/home/user/.agents/skills/global-only/SKILL.md", - enabled: true, - scope: "user", - }, - { - name: "project-only", - path: "/workspace/.agents/skills/project-only/SKILL.md", - enabled: true, - scope: "project", - }, +describe("workspace provider snapshots", () => { + it("uses the cwd snapshot after a provider session has populated it", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }, + ]); + expect(resolveProviderSlashCommandsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project" }, ]); }); + + it("keeps the machine snapshot before this cwd has a provider snapshot", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); + expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); + }); }); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index c42fd170ee6c..566baeb8b0c2 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,4 +1,4 @@ -import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { ServerProvider, ServerProviderSkill } from "@t3tools/contracts"; export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; @@ -55,13 +55,24 @@ export function resolveProviderSkillSourceKind( } } -export function mergeProviderSkills( - providerSkills: ReadonlyArray, - projectSkills: ReadonlyArray, -): ServerProviderSkill[] { - const skillsByName = new Map(providerSkills.map((skill) => [skill.name, skill])); - for (const skill of projectSkills) { - skillsByName.set(skill.name, skill); - } - return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +function resolveProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string | null | undefined, +) { + if (!cwd) return undefined; + return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); +} + +export function resolveProviderSkillsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["skills"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; +} + +export function resolveProviderSlashCommandsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["slashCommands"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; } diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 4b4654b48df8..3defcc321547 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -66,12 +66,6 @@ export function createProjectEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), - listSkills: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:projects:list-skills", - tag: WS_METHODS.projectsListSkills, - staleTimeMs: 30_000, - idleTtlMs: 5 * 60_000, - }), readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 90e3c4f36b7c..51c65f50e1a2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,7 +8,6 @@ import { AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BackgroundPolicySnapshot, ClientActivityReportInput, @@ -67,7 +66,7 @@ import { OrchestrationRpcSchemas, OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; -import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -155,7 +154,6 @@ import { import { ServerConfigStreamEvent, ServerConfig, - ServerProviderSkill, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -201,7 +199,6 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", - projectsListSkills: "projects.listSkills", projectsReadFile: "projects.readFile", projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", @@ -639,15 +636,6 @@ export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsListSkillsRpc = Rpc.make(WS_METHODS.projectsListSkills, { - payload: Schema.Struct({ - cwd: TrimmedNonEmptyString, - driver: ProviderDriverKind, - }), - success: Schema.Array(ServerProviderSkill), - error: EnvironmentAuthorizationError, -}); - export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, @@ -1039,7 +1027,6 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, - WsProjectsListSkillsRpc, WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..a6f9187a1f93 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -96,6 +96,14 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderWorkspaceSnapshot = Schema.Struct({ + cwd: TrimmedNonEmptyString, + checkedAt: IsoDateTime, + slashCommands: Schema.Array(ServerProviderSlashCommand), + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -192,6 +200,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); From 48e7493574be0d8a81bc75467eee61bab5e77158 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:49:22 +0000 Subject: [PATCH 05/13] fix(provider): refresh scoped snapshots consistently Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../check-run-agents/ui-consistency.md | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.ts | 22 +++++++++---------- .../src/provider/Drivers/CodexDriver.ts | 2 +- .../src/provider/Layers/ProviderRegistry.ts | 15 +++++++++++++ apps/web/src/components/ChatView.tsx | 7 +++++- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index c2c091b205cf..1a6a5c5b2673 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -72,6 +72,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put ## Change discipline +- When a provider skill catalog becomes workspace-scoped, trace every renderer that resolves `$skill` tokens, including composer menus/editors and sent-message timelines on web and mobile. The same token must not render as a chip before send and raw text afterward. - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. - Prefer the smallest durable contract over a component-specific workaround or a broad abstraction with one consumer. - Preserve intentional exceptions and comments that explain browser, virtualizer, theme, or Electron constraints. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8a1a5a8c0e0a..9127fabb8943 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1915,6 +1915,7 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); + await waitFor(() => harness.refreshWorkspaceSnapshot.mock.calls.length === 2); expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d03cfb8b9a82..120765f12be8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -604,6 +604,14 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + const refreshWorkspaceSnapshot = effectiveCwd + ? providerRegistry + .refreshWorkspaceSnapshot({ + instanceId: desiredInstanceId, + cwd: effectiveCwd, + }) + .pipe(Effect.forkDetach) + : Effect.void; const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -620,18 +628,7 @@ const make = Effect.gen(function* () { ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }) - .pipe( - Effect.tap(() => - effectiveCwd - ? providerRegistry - .refreshWorkspaceSnapshot({ - instanceId: desiredInstanceId, - cwd: effectiveCwd, - }) - .pipe(Effect.forkDetach) - : Effect.void, - ), - ); + .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -689,6 +686,7 @@ const make = Effect.gen(function* () { !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { + yield* refreshWorkspaceSnapshot; return existingSessionThreadId; } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 885a3af2fd37..9acf415d51e8 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -179,7 +179,7 @@ export const CodexDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to probe Codex snapshot for '${cwd}': ${cause.message}`, + detail: `Failed to probe Codex snapshot for '${cwd}'`, cause, }), ), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 57a7d9b35f51..a08db75f04cb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -589,6 +589,21 @@ export const ProviderRegistryLive = Layer.effect( newlyAdded.push([instanceId, instance] as const); } + const rebuiltInstanceIds = new Set( + newlyAdded + .map(([instanceId]) => instanceId) + .filter((instanceId) => previousSubs.has(instanceId)), + ); + if (rebuiltInstanceIds.size > 0) { + yield* Ref.update(providersRef, (providers) => + providers.map((provider) => { + if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + }), + ); + } + // Fork long-lived subscriptions to each new/rebuilt instance's // change stream before reading its current snapshot. If the // driver's own initial probe finishes during this sync, either diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bbee2d1709f3..2de78adcb547 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { changeRequestAutoSettles, effectiveSettled, @@ -6657,7 +6658,11 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={ + activeProviderStatus + ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) + : EMPTY_PROVIDER_SKILLS + } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerOverlayHeight} From e6127f6fab085e77c40ac479c5ed51d81fb53251 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:57:04 +0000 Subject: [PATCH 06/13] fix(provider): make scoped snapshot updates atomic Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../effect-service-conventions.md | 1 + .../src/features/threads/ThreadComposer.tsx | 6 +-- .../features/threads/ThreadDetailScreen.tsx | 1 - .../src/provider/Layers/ProviderRegistry.ts | 49 ++++++++++++------- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 730535803c31..da9486143058 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -82,6 +82,7 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline - Keep provider capability discovery provider-owned. Project/worktree catalogs must be cached by provider instance plus cwd, preserve the machine snapshot as the pre-session fallback, and never overwrite one project's menus with another cwd's snapshot. Flag generic filesystem reimplementations of provider discovery when the driver can probe from the active session cwd. +- Treat provider-instance rebuilds as cache-generation changes: clear and publish volatile cwd catalogs, reject results from stale instance objects, and attach scoped data to the current machine snapshot inside one atomic `Ref.modify` so concurrent status refreshes cannot be rolled back. - Preserve useful comments, invariants, and specification documentation while moving code. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. - If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6903f6f768d3..45b1457a911b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -113,7 +113,6 @@ export interface ThreadComposerProps { readonly queueCount: number; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; - readonly threadCwd: string | null; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; @@ -360,11 +359,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const workspaceSnapshotCwd = props.selectedThread.worktreePath ?? props.projectCwd; const selectedProviderSkills = selectedProviderStatus - ? resolveProviderSkillsForCwd(selectedProviderStatus, props.threadCwd) + ? resolveProviderSkillsForCwd(selectedProviderStatus, workspaceSnapshotCwd) : []; const selectedProviderSlashCommands = selectedProviderStatus - ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, props.threadCwd) + ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, workspaceSnapshotCwd) : []; // ── Trigger detection ──────────────────────────────────── diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4c2dacc1049d..2c6860199722 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -754,7 +754,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread queueCount={props.selectedThreadQueueCount} environmentId={props.environmentId} projectCwd={props.projectWorkspaceRoot} - threadCwd={props.threadCwd} bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} onPickDraftImages={props.onPickDraftImages} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a08db75f04cb..ccc81a42803e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -595,13 +595,20 @@ export const ProviderRegistryLive = Layer.effect( .filter((instanceId) => previousSubs.has(instanceId)), ); if (rebuiltInstanceIds.size > 0) { - yield* Ref.update(providersRef, (providers) => - providers.map((provider) => { - if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; - const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; - return machineSnapshot; - }), + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = previousProviders.map((provider) => { + if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + }); + return [[previousProviders, providers] as const, providers]; + }, ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } } // Fork long-lived subscriptions to each new/rebuilt instance's @@ -769,20 +776,26 @@ export const ProviderRegistryLive = Layer.effect( } const source = buildSnapshotSource(instance); const correlatedSnapshot = yield* correlateSnapshotWithSource(source, scopedSnapshot); - const latestProviders = yield* Ref.get(providersRef); - const latestMachineSnapshot = latestProviders.find( - (provider) => provider.instanceId === input.instanceId, - ); - if ( - latestMachineSnapshot === undefined || - latestMachineSnapshot.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) - ) { - return latestProviders; + const currentInstance = yield* instanceRegistry.getInstance(input.instanceId); + if (currentInstance !== instance) { + return yield* Ref.get(providersRef); } - return yield* upsertProviders( - [upsertProviderWorkspaceSnapshot(latestMachineSnapshot, input.cwd, correlatedSnapshot)], - { persist: false, replace: true }, + const [previousProviders, nextProviders] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const nextProviders = previousProviders.map((provider) => + provider.instanceId === input.instanceId && + !provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ? upsertProviderWorkspaceSnapshot(provider, input.cwd, correlatedSnapshot) + : provider, + ); + return [[previousProviders, nextProviders] as const, nextProviders]; + }, ); + if (haveProvidersChanged(previousProviders, nextProviders)) { + yield* PubSub.publish(changesPubSub, nextProviders); + } + return nextProviders; }); return { From 236bb1fbbc743ce19a2e665fbd38943e4f999cab Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:11:20 +0000 Subject: [PATCH 07/13] test(provider): cover workspace snapshot lifecycle Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../provider/Layers/ProviderRegistry.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index a18a584874c9..96ef24e826e3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -949,6 +949,132 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("publishes cwd snapshots and clears them when the provider instance rebuilds", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const machineProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const scopedProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const makeInstance = ( + provider: ServerProvider, + snapshotForCwd: NonNullable, + ): ProviderInstance => ({ + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + snapshotForCwd, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const firstInstance = makeInstance(machineProvider, () => + Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), + ); + const rebuiltProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + } satisfies ServerProvider; + const rebuiltInstance = makeInstance(rebuiltProvider, () => + Effect.succeed(scopedProvider), + ); + const registryChanges = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([firstInstance]); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instancesRef), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(registryChanges), + subscribeChanges: PubSub.subscribe(registryChanges), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-workspace-snapshot-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const workspaceUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const published = yield* Fiber.join(workspaceUpdate); + assert.strictEqual(published._tag, "Some"); + const providers = yield* registry.getProviders; + assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.[0]?.skills, + scopedProvider.skills, + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 1); + + yield* Ref.set(instancesRef, [rebuiltInstance]); + yield* PubSub.publish(registryChanges, undefined); + let rebuilt = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + rebuilt = yield* registry.getProviders; + } + assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); + assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); From 365893902675a0e68d1e31a408e612734c0b6d49 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:54:37 +0000 Subject: [PATCH 08/13] fix(provider): probe workspace skills without full inventory Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../effect-service-conventions.md | 1 + .../src/provider/Drivers/CodexDriver.ts | 53 ++++++++++++------- .../src/provider/Drivers/OpenCodeDriver.ts | 36 ++++++++++--- .../src/provider/Layers/CodexProvider.ts | 47 ++++++++++++++++ .../provider/Layers/OpenCodeAdapter.test.ts | 8 +++ .../provider/Layers/OpenCodeProvider.test.ts | 2 + .../src/provider/Layers/OpenCodeProvider.ts | 8 +-- apps/server/src/provider/opencodeRuntime.ts | 25 +++++++++ .../OpenCodeTextGeneration.test.ts | 8 +++ 9 files changed, 159 insertions(+), 29 deletions(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index da9486143058..0b428aa6508b 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -82,6 +82,7 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline - Keep provider capability discovery provider-owned. Project/worktree catalogs must be cached by provider instance plus cwd, preserve the machine snapshot as the pre-session fallback, and never overwrite one project's menus with another cwd's snapshot. Flag generic filesystem reimplementations of provider discovery when the driver can probe from the active session cwd. +- Keep cwd catalog refreshes narrow: query only skills and slash commands needed by the menu. Do not reuse full provider health/model inventory probes for a post-session catalog refresh, because unrelated model/auth commands can block the catalog indefinitely. - Treat provider-instance rebuilds as cache-generation changes: clear and publish volatile cwd catalogs, reject results from stale instance objects, and attach scoped data to the current machine snapshot inside one atomic `Ref.modify` so concurrent status refreshes cannot be rolled back. - Preserve useful comments, invariants, and specification documentation while moving code. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 9acf415d51e8..252bcc18153a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -36,7 +36,11 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + probeCodexSkillsForCwd, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -166,24 +170,10 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProviderForCwd = (cwd?: string) => - checkCodexProviderStatus(effectiveConfig, undefined, processEnv, cwd).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const checkProvider = checkProviderForCwd(); - const snapshotForCwd = (cwd: string) => - checkProviderForCwd(cwd).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to probe Codex snapshot for '${cwd}'`, - cause, - }), - ), - ); + const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, @@ -211,6 +201,31 @@ export const CodexDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (cwd: string) => + Effect.all([ + snapshot.getSnapshot, + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: effectiveConfig.launchArgs, + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Codex skills for '${cwd}'`, + cause, + }), + ), + ); return { instanceId, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index ec7866204235..2b1bbdc570a3 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, makePendingOpenCodeProvider, + openCodeSkillsToServerProviderSkills, } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -143,12 +144,11 @@ export const OpenCodeDriver: ProviderDriver }); const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); - const checkProviderForCwd = (cwd: string) => - checkOpenCodeProviderStatus(effectiveConfig, cwd, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(OpenCodeRuntime, openCodeRuntime), - ); - const checkProvider = checkProviderForCwd(serverConfig.cwd); + const checkProvider = checkOpenCodeProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( @@ -188,7 +188,29 @@ export const OpenCodeDriver: ProviderDriver accentColor, enabled, snapshot, - snapshotForCwd: checkProviderForCwd, + snapshotForCwd: (cwd) => + Effect.all([ + snapshot.getSnapshot, + openCodeRuntime.loadSkillsFromCli({ + binaryPath: effectiveConfig.binaryPath, + cwd, + environment: processEnv, + }), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode skills for '${cwd}'`, + cause, + }), + ), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 39dd6ad0819e..82bcc1b09c90 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -421,6 +421,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { env: environment, extendEnv: true }, + ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(skillsResponse, input.cwd); +}); + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); for (const model of codexSettings.customModels) { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..1d0e48a1712a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -229,6 +229,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: "OpenCodeRuntimeTestDouble.loadSkillsFromCli not used in this test", + cause: null, + }), + ), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..44f3aff00b83 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -111,6 +111,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); }, + loadSkillsFromCli: () => + Effect.succeed((runtimeMock.state.inventory as OpenCodeInventory).skills), }; beforeEach(() => { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..909320c03515 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -256,9 +256,11 @@ function trimOptional(value: string | null | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined; } -function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { +export function openCodeSkillsToServerProviderSkills( + input: OpenCodeInventory["skills"] | undefined, +): ReadonlyArray { const skills: ServerProviderSkill[] = []; - for (const skill of input.skills ?? []) { + for (const skill of input ?? []) { const name = trimOptional(skill.name); const path = trimOptional(skill.location); if (!name || !path) { @@ -457,7 +459,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.skills); const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..1f43bbaa1523 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -183,6 +183,11 @@ export interface OpenCodeRuntimeShape { readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect; + readonly loadSkillsFromCli: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect, OpenCodeRuntimeError>; } function parseServerUrlFromOutput(output: string): string | null { @@ -809,6 +814,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }; }); + const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + cwd: input.cwd, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed(parseSkillsCliOutput(result.stdout)) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: `OpenCode skills command exited with code ${result.code}.`, + }), + ), + ), + ); + return { startOpenCodeServerProcess, connectToOpenCodeServer, @@ -816,6 +840,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { createOpenCodeSdkClient, loadOpenCodeInventory, loadInventoryFromCli, + loadSkillsFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..9cf4a4ddaf20 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -115,6 +115,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: "OpenCodeRuntimeTestDouble.loadSkillsFromCli not used in this test", + cause: null, + }), + ), }; const DEFAULT_TEST_MODEL_SELECTION = { From 6269301e5e3534f2cf60fd3fe05ad5b528fdfb99 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:02:11 +0000 Subject: [PATCH 09/13] fix(provider): narrow cwd catalog probes Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/provider/Drivers/CodexDriver.ts | 4 ++- .../src/provider/Drivers/CursorDriver.ts | 17 ++++------ .../server/src/provider/Drivers/GrokDriver.ts | 13 +++----- .../src/provider/Drivers/OpenCodeDriver.ts | 32 ++++++++++++++++--- .../src/provider/Layers/CursorProvider.ts | 16 +++------- .../src/provider/Layers/GrokProvider.ts | 6 ++-- 6 files changed, 49 insertions(+), 39 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 252bcc18153a..b59259c64d03 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -41,6 +41,7 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, } from "../Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -207,11 +208,12 @@ export const CodexDriver: ProviderDriver = { probeCodexSkillsForCwd({ binaryPath: effectiveConfig.binaryPath, homePath: effectiveConfig.homePath, - launchArgs: effectiveConfig.launchArgs, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), cwd, environment: processEnv, }).pipe( Effect.scoped, + Effect.timeout("20 seconds"), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ]).pipe( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index e5f5e747b954..2101664d5cb1 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -132,15 +132,13 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProviderForCwd = (cwd?: string) => - checkCursorProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.map(stampIdentity), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - const checkProvider = checkProviderForCwd(); + const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ @@ -183,7 +181,6 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, - snapshotForCwd: checkProviderForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 17d3fad137e3..112f11013161 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -113,13 +113,11 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProviderForCwd = (cwd?: string) => - checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.map(stampIdentity), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const checkProvider = checkProviderForCwd(); + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ @@ -158,7 +156,6 @@ export const GrokDriver: ProviderDriver = { accentColor, enabled, snapshot, - snapshotForCwd: checkProviderForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 2b1bbdc570a3..5c5e75c7c95e 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -179,6 +179,32 @@ export const OpenCodeDriver: ProviderDriver }), ), ); + const loadSkillsForCwd = (cwd: string) => + effectiveConfig.serverUrl.trim().length > 0 + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: effectiveConfig.binaryPath, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + }); + const inventory = yield* openCodeRuntime.loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + }), + ); + return inventory.skills; + }), + ) + : openCodeRuntime.loadSkillsFromCli({ + binaryPath: effectiveConfig.binaryPath, + cwd, + environment: processEnv, + }); return { instanceId, @@ -191,11 +217,7 @@ export const OpenCodeDriver: ProviderDriver snapshotForCwd: (cwd) => Effect.all([ snapshot.getSnapshot, - openCodeRuntime.loadSkillsFromCli({ - binaryPath: effectiveConfig.binaryPath, - cwd, - environment: processEnv, - }), + loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")), ]).pipe( Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 7b45b1e6544d..fee4306c4c5c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -403,7 +403,6 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, - cwd: string = process.cwd(), ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -415,10 +414,10 @@ const makeCursorAcpProbeRuntime = ( ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", ], - cwd, + cwd: process.cwd(), ...(environment ? { env: environment } : {}), }, - cwd, + cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -433,9 +432,8 @@ const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, environment?: NodeJS.ProcessEnv, - cwd?: string, ) => - makeCursorAcpProbeRuntime(cursorSettings, environment, cwd).pipe( + makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( Effect.flatMap(useRuntime), Effect.scoped, ); @@ -555,7 +553,6 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, - cwd?: string, ) => withCursorAcpProbeRuntime( cursorSettings, @@ -567,14 +564,12 @@ const discoverCursorModelsViaListAvailableModels = ( return buildCursorDiscoveredModelsFromAvailableModelsResponse(decoded); }), environment, - cwd, ); export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, - cwd?: string, -) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment, cwd); +) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( cursorSettings: Pick, @@ -992,7 +987,6 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, - cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, never, @@ -1090,7 +1084,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment, cwd).pipe( + discoverCursorModelsViaAcp(cursorSettings, environment).pipe( Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), ), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 15aaf2796c77..934eecdb5ae6 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -126,7 +126,6 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, - cwd: string = process.cwd(), ) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -134,7 +133,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd, + cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -162,7 +161,6 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, - cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, never, @@ -253,7 +251,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment, cwd).pipe( + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, ); From b1ebbd101360a0479563533891352986ea57f127 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:08:38 +0000 Subject: [PATCH 10/13] fix(opencode): query remote skills directly Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/server/src/provider/Drivers/OpenCodeDriver.ts | 3 +-- apps/server/src/provider/Layers/OpenCodeAdapter.test.ts | 1 + apps/server/src/provider/Layers/OpenCodeProvider.test.ts | 2 ++ apps/server/src/provider/opencodeRuntime.ts | 5 +++++ .../server/src/textGeneration/OpenCodeTextGeneration.test.ts | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 5c5e75c7c95e..2402b2f59bae 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -188,7 +188,7 @@ export const OpenCodeDriver: ProviderDriver serverUrl: effectiveConfig.serverUrl, environment: processEnv, }); - const inventory = yield* openCodeRuntime.loadOpenCodeInventory( + return yield* openCodeRuntime.loadOpenCodeSkills( openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, @@ -197,7 +197,6 @@ export const OpenCodeDriver: ProviderDriver : {}), }), ); - return inventory.skills; }), ) : openCodeRuntime.loadSkillsFromCli({ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1d0e48a1712a..a3d449ea226f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -221,6 +221,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntimeError({ diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 44f3aff00b83..fba707794575 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -99,6 +99,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + loadOpenCodeSkills: () => + Effect.succeed((runtimeMock.state.inventory as OpenCodeInventory).skills ?? []), loadInventoryFromCli: ({ cwd }) => { runtimeMock.state.inventoryCwd = cwd; return runtimeMock.state.inventoryError diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 1f43bbaa1523..34327905486a 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -178,6 +178,9 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadOpenCodeSkills: ( + client: OpencodeClient, + ) => Effect.Effect, never>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; @@ -710,6 +713,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map((result) => (result.data ?? []) as ReadonlyArray), Effect.orElseSucceed((): ReadonlyArray => []), ); + const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = loadSkills; const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { @@ -839,6 +843,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadOpenCodeSkills, loadInventoryFromCli, loadSkillsFromCli, } satisfies OpenCodeRuntimeShape; diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 9cf4a4ddaf20..b47a98c2d95b 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -107,6 +107,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ From 7125ec3e76ba873b2498a814896d42b24fe2abe3 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:18:31 +0000 Subject: [PATCH 11/13] fix(opencode): preserve fallback on skill errors Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../provider/opencodeRuntime.inventory.test.ts | 15 +++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 8 ++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 8b22a52a2060..34d5d0b3b6c5 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -38,4 +38,19 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { NodeAssert.deepEqual(inventory.skills, []); }), ); + + it.effect("keeps direct skill discovery failures visible", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + app: { + skills: () => Promise.reject(new Error("skills endpoint unavailable")), + }, + } as unknown as OpencodeClient; + + const error = yield* runtime.loadOpenCodeSkills(client).pipe(Effect.flip); + + NodeAssert.equal(error.operation, "app.skills"); + }), + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 34327905486a..9b21602cb9ab 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -180,7 +180,7 @@ export interface OpenCodeRuntimeShape { ) => Effect.Effect; readonly loadOpenCodeSkills: ( client: OpencodeClient, - ) => Effect.Effect, never>; + ) => Effect.Effect, OpenCodeRuntimeError>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; @@ -708,12 +708,12 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map((result) => result.data ?? []), ); - const loadSkills = (client: OpencodeClient) => + const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( Effect.map((result) => (result.data ?? []) as ReadonlyArray), - Effect.orElseSucceed((): ReadonlyArray => []), ); - const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = loadSkills; + const loadSkills = (client: OpencodeClient) => + loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { From 97f2e0d523be2012261da757abb0af185701ed8c Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:24:24 +0000 Subject: [PATCH 12/13] fix(mobile): render workspace skill chips in feed Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../features/threads/ThreadDetailScreen.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2c6860199722..756ecec6f48a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,5 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; @@ -447,12 +448,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo( - () => - props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) - ?.skills ?? [], - [props.serverConfig, selectedInstanceId], - ); + const selectedProviderSkills = useMemo(() => { + const provider = props.serverConfig?.providers.find( + (candidate) => candidate.instanceId === selectedInstanceId, + ); + return provider + ? resolveProviderSkillsForCwd( + provider, + props.selectedThread.worktreePath ?? props.projectWorkspaceRoot, + ) + : []; + }, [ + props.projectWorkspaceRoot, + props.selectedThread.worktreePath, + props.serverConfig, + selectedInstanceId, + ]); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; From 698e2409b14bb5c136b40bb592369ba8d17311f0 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:31:31 +0000 Subject: [PATCH 13/13] fix(claude): keep fallback when cwd probe fails Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- .../src/provider/Drivers/ClaudeDriver.ts | 36 ++++++++++--------- .../src/provider/Layers/ClaudeProvider.ts | 34 ++++++++++++++++-- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index a4881f841639..9f43b85d68ca 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -33,6 +33,7 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + probeClaudeWorkspaceCatalog, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -174,22 +175,6 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); - const snapshotForCwd = (workspaceCwd: string) => - checkClaudeProviderStatus( - effectiveConfig, - () => - probeClaudeCapabilities(effectiveConfig, processEnv, workspaceCwd).pipe( - Effect.provideService(Path.Path, path), - ), - processEnv, - workspaceCwd, - ).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, @@ -217,6 +202,25 @@ export const ClaudeDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (workspaceCwd: string) => + Effect.all([ + snapshot.getSnapshot, + probeClaudeWorkspaceCatalog(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ]).pipe( + Effect.map(([machineSnapshot, catalog]) => ({ ...machineSnapshot, ...catalog })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Claude catalog for '${workspaceCwd}'`, + cause, + }), + ), + ); return { instanceId, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..d192aabd84ce 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -631,7 +631,7 @@ function nonEmptyProbeString(value: string): string | undefined { return candidate ? candidate : undefined; } -type ClaudeCapabilitiesProbe = { +export type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; @@ -729,7 +729,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = ( +const probeClaudeCapabilitiesStrict = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, @@ -779,6 +779,15 @@ const probeClaudeCapabilities = ( if (!abort.signal.aborted) abort.abort(); }), ), + ); +}; + +const probeClaudeCapabilities = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, +) => + probeClaudeCapabilitiesStrict(claudeSettings, environment, cwd).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -786,7 +795,26 @@ const probeClaudeCapabilities = ( return Option.isSome(result.success) ? result.success.value : undefined; }), ); -}; + +export const probeClaudeWorkspaceCatalog = Effect.fn("probeClaudeWorkspaceCatalog")(function* ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv | undefined, + cwd: string, +) { + const [capabilities, skills] = yield* Effect.all( + [ + probeClaudeCapabilitiesStrict(claudeSettings, environment, cwd).pipe( + Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), + ), + discoverClaudeSkills(claudeSettings, cwd, environment ?? process.env), + ], + { concurrency: "unbounded" }, + ); + return { + skills, + slashCommands: dedupeSlashCommands(capabilities.slashCommands), + }; +}); const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings,