authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-09-02 11:09:01+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 01:39:01-04:00
log80a14b6588f0e856983e6d10002cde9e00e9d3e2
treeacf5311537428750cae2ee530763920840f95f91
parent082358f9ef6feaadfaafd59a7512da26beb00100
signature Signed by PGP key B5690EEEBB952194

fix(server): discover project skills for Codex and OpenCode (#8778)


24 files changed, 692 insertions(+), 40 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+18-10
......@@ -657,21 +657,28 @@ const make = Effect.gen(function* () {
657657 thread,
658658 projects: project ? [project] : [],
659659 });
660 const refreshWorkspaceSnapshot = effectiveCwd
661 ? providerRegistry
662 .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd })
663 .pipe(Effect.forkDetach)
664 : Effect.void;
660665
661666 const startProviderSession = (input?: {
662667 readonly resumeCursor?: unknown;
663668 readonly provider?: ProviderDriverKind;
664669 }) =>
665 providerService.startSession(threadId, {
666 threadId,
667 ...(preferredProvider ? { provider: preferredProvider } : {}),
668 providerInstanceId: desiredInstanceId,
669 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
670 ...(thread.title ? { title: thread.title } : {}),
671 modelSelection: desiredModelSelection,
672 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
673 runtimeMode: desiredRuntimeMode,
674 });
670 providerService
671 .startSession(threadId, {
672 threadId,
673 ...(preferredProvider ? { provider: preferredProvider } : {}),
674 providerInstanceId: desiredInstanceId,
675 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
676 ...(thread.title ? { title: thread.title } : {}),
677 modelSelection: desiredModelSelection,
678 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
679 runtimeMode: desiredRuntimeMode,
680 })
681 .pipe(Effect.tap(() => refreshWorkspaceSnapshot));
675682
676683 const bindSessionToThread = (session: ProviderSession) =>
677684 Effect.gen(function* () {
......@@ -729,6 +736,7 @@ const make = Effect.gen(function* () {
729736 !shouldRestartForModelChange &&
730737 !shouldRestartForModelSelectionChange
731738 ) {
739 yield* refreshWorkspaceSnapshot;
732740 return existingSessionThreadId;
733741 }
734742
apps/server/src/provider/Drivers/CodexDriver.ts+35-1
......@@ -36,7 +36,12 @@ import { ServerConfig } from "../../config.ts";
3636import { ServerSettingsService } from "../../serverSettings.ts";
3737import { ProviderDriverError } from "../Errors.ts";
3838import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
39import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
39import {
40 checkCodexProviderStatus,
41 makePendingCodexProvider,
42 probeCodexSkillsForCwd,
43} from "../Layers/CodexProvider.ts";
44import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
4045import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
4146import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4247import * as ModelManifest from "../ModelManifest.ts";
......@@ -195,6 +200,34 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
195200 }),
196201 ),
197202 );
203 const snapshotForCwd = (cwd: string) =>
204 !effectiveConfig.enabled
205 ? snapshot.getSnapshot
206 : Effect.all([
207 snapshot.getSnapshot,
208 probeCodexSkillsForCwd({
209 binaryPath: effectiveConfig.binaryPath,
210 homePath: effectiveConfig.homePath,
211 launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
212 cwd,
213 environment: processEnv,
214 }).pipe(
215 Effect.scoped,
216 Effect.timeout("20 seconds"),
217 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
218 ),
219 ]).pipe(
220 Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
221 Effect.mapError(
222 (cause) =>
223 new ProviderDriverError({
224 driver: DRIVER_KIND,
225 instanceId,
226 detail: `Failed to probe Codex skills for '${cwd}'`,
227 cause,
228 }),
229 ),
230 );
198231
199232 return {
200233 instanceId,
......@@ -204,6 +237,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
204237 accentColor,
205238 enabled,
206239 snapshot,
240 snapshotForCwd,
207241 adapter,
208242 textGeneration,
209243 } satisfies ProviderInstance;
apps/server/src/provider/Drivers/OpenCodeDriver.ts+50
......@@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts";
3030import {
3131 checkOpenCodeProviderStatus,
3232 makePendingOpenCodeProvider,
33 openCodeSkillsToServerProviderSkills,
3334} from "../Layers/OpenCodeProvider.ts";
3435import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
3536import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
......@@ -148,6 +149,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
148149 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
149150 Effect.provideService(OpenCodeRuntime, openCodeRuntime),
150151 );
152 const loadSkillsForCwd = (cwd: string) =>
153 effectiveConfig.serverUrl.trim().length > 0
154 ? Effect.scoped(
155 Effect.gen(function* () {
156 const server = yield* openCodeRuntime.connectToOpenCodeServer({
157 binaryPath: effectiveConfig.binaryPath,
158 directory: cwd,
159 serverUrl: effectiveConfig.serverUrl,
160 ...(effectiveConfig.serverPassword
161 ? { serverPassword: effectiveConfig.serverPassword }
162 : {}),
163 environment: processEnv,
164 });
165 const client = openCodeRuntime.createOpenCodeSdkClient({
166 baseUrl: server.url,
167 directory: cwd,
168 ...(effectiveConfig.serverPassword
169 ? { serverPassword: effectiveConfig.serverPassword }
170 : {}),
171 });
172 return yield* openCodeRuntime.loadOpenCodeSkills(client);
173 }),
174 )
175 : openCodeRuntime.loadSkillsFromCli({
176 binaryPath: effectiveConfig.binaryPath,
177 cwd,
178 environment: processEnv,
179 });
151180
152181 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
153182 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
......@@ -189,6 +218,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
189218 accentColor,
190219 enabled,
191220 snapshot,
221 snapshotForCwd: (cwd) =>
222 !effectiveConfig.enabled
223 ? snapshot.getSnapshot
224 : Effect.all([
225 snapshot.getSnapshot,
226 loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")),
227 ]).pipe(
228 Effect.map(([machineSnapshot, skills]) => ({
229 ...machineSnapshot,
230 skills: openCodeSkillsToServerProviderSkills(skills),
231 })),
232 Effect.mapError(
233 (cause) =>
234 new ProviderDriverError({
235 driver: DRIVER_KIND,
236 instanceId,
237 detail: `Failed to probe OpenCode skills for '${cwd}'`,
238 cause,
239 }),
240 ),
241 ),
192242 adapter,
193243 textGeneration,
194244 } satisfies ProviderInstance;
apps/server/src/provider/Layers/CodexProvider.ts+47
......@@ -414,6 +414,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
414414 } satisfies CodexAppServerProviderSnapshot;
415415});
416416
417export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: {
418 readonly binaryPath: string;
419 readonly homePath?: string;
420 readonly launchArgs?: string;
421 readonly cwd: string;
422 readonly environment?: NodeJS.ProcessEnv;
423}) {
424 const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
425 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
426 const environment = {
427 ...input.environment,
428 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
429 };
430 const spawnCommand = yield* resolveSpawnCommand(
431 input.binaryPath,
432 codexAppServerArgs(input.launchArgs),
433 { env: environment, extendEnv: true },
434 );
435 const child = yield* spawner
436 .spawn(
437 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
438 cwd: input.cwd,
439 env: environment,
440 extendEnv: true,
441 forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
442 shell: spawnCommand.shell,
443 }),
444 )
445 .pipe(
446 Effect.mapError(
447 (cause) =>
448 new CodexErrors.CodexAppServerSpawnError({
449 command: `${input.binaryPath} app-server`,
450 cause,
451 }),
452 ),
453 );
454 const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
455 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
456 Effect.provide(clientContext),
457 );
458 yield* client.request("initialize", buildCodexInitializeParams());
459 yield* client.notify("initialized", undefined);
460 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
461 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
462});
463
417464const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
418465 const models = new Set<string>();
419466 for (const model of codexSettings.customModels) {
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+2
......@@ -407,6 +407,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
407407 cause: null,
408408 }),
409409 ),
410 loadOpenCodeSkills: () => Effect.succeed([]),
410411 loadInventoryFromCli: () =>
411412 Effect.fail(
412413 new OpenCodeRuntimeError({
......@@ -415,6 +416,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
415416 cause: null,
416417 }),
417418 ),
419 loadSkillsFromCli: () => Effect.succeed([]),
418420};
419421
420422const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+2
......@@ -149,6 +149,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
149149 )
150150 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory);
151151 },
152 loadOpenCodeSkills: () => Effect.succeed([]),
153 loadSkillsFromCli: () => Effect.succeed([]),
152154};
153155
154156beforeEach(() => {
apps/server/src/provider/Layers/OpenCodeProvider.ts+5-3
......@@ -260,9 +260,11 @@ function trimOptional(value: string | null | undefined): string | undefined {
260260 return trimmed && trimmed.length > 0 ? trimmed : undefined;
261261}
262262
263function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray<ServerProviderSkill> {
263export function openCodeSkillsToServerProviderSkills(
264 input: OpenCodeInventory["skills"] | undefined,
265): ReadonlyArray<ServerProviderSkill> {
264266 const skills: ServerProviderSkill[] = [];
265 for (const skill of input.skills ?? []) {
267 for (const skill of input ?? []) {
266268 const name = trimOptional(skill.name);
267269 const path = trimOptional(skill.location);
268270 if (!name || !path) {
......@@ -475,7 +477,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
475477 customModels,
476478 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
477479 );
478 const skills = flattenOpenCodeSkills(inventoryExit.value.inventory);
480 const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills);
479481 const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
480482 return buildServerProvider({
481483 presentation: OPENCODE_PRESENTATION,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+195
......@@ -1,6 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { describe, it, assert } from "@effect/vitest";
33import * as DateTime from "effect/DateTime";
4import * as Deferred from "effect/Deferred";
45import * as Effect from "effect/Effect";
56import * as Exit from "effect/Exit";
67import * as Fiber from "effect/Fiber";
......@@ -41,6 +42,7 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr
4142import {
4243 haveProvidersChanged,
4344 mergeProviderSnapshot,
45 upsertProviderWorkspaceSnapshot,
4446 ProviderRegistryLive,
4547} from "./ProviderRegistry.ts";
4648import * as ServerConfig from "../../config.ts";
......@@ -563,6 +565,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
563565 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);
564566 });
565567
568 it("stores workspace skills and commands without changing machine metadata", () => {
569 const provider = {
570 instanceId: ProviderInstanceId.make("codex"),
571 driver: ProviderDriverKind.make("codex"),
572 status: "ready",
573 enabled: true,
574 installed: true,
575 auth: { status: "authenticated" },
576 checkedAt: "2026-03-25T00:00:00.000Z",
577 version: "1.0.0",
578 models: [],
579 slashCommands: [{ name: "global" }],
580 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
581 } satisfies ServerProvider;
582 const scopedSnapshot = {
583 ...provider,
584 checkedAt: "2026-03-25T00:01:00.000Z",
585 slashCommands: [{ name: "project" }],
586 skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }],
587 } satisfies ServerProvider;
588
589 const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot);
590
591 assert.deepStrictEqual(result.slashCommands, provider.slashCommands);
592 assert.deepStrictEqual(result.skills, provider.skills);
593 assert.deepStrictEqual(result.workspaceSnapshots, [
594 {
595 cwd: "/project",
596 checkedAt: scopedSnapshot.checkedAt,
597 slashCommands: scopedSnapshot.slashCommands,
598 skills: scopedSnapshot.skills,
599 },
600 ]);
601 });
602
566603 it("preserves previously discovered provider models when a refresh returns none", () => {
567604 const previousProvider = {
568605 instanceId: ProviderInstanceId.make("cursor"),
......@@ -983,6 +1020,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
9831020 }),
9841021 );
9851022
1023 it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () =>
1024 Effect.gen(function* () {
1025 const driver = ProviderDriverKind.make("codex");
1026 const instanceId = ProviderInstanceId.make("codex");
1027 const machineProvider = {
1028 instanceId,
1029 driver,
1030 status: "ready",
1031 enabled: true,
1032 installed: true,
1033 auth: { status: "authenticated" },
1034 checkedAt: "2026-06-10T00:00:00.000Z",
1035 version: "1.0.0",
1036 models: [],
1037 slashCommands: [{ name: "global" }],
1038 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
1039 } as const satisfies ServerProvider;
1040 const scopedProvider = {
1041 ...machineProvider,
1042 checkedAt: "2026-06-10T00:01:00.000Z",
1043 slashCommands: [{ name: "project" }],
1044 skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }],
1045 } as const satisfies ServerProvider;
1046 const pendingScopedProvider = {
1047 ...scopedProvider,
1048 status: "error",
1049 installed: false,
1050 slashCommands: [],
1051 } as const satisfies ServerProvider;
1052 const snapshotCalls = yield* Ref.make(0);
1053 const returnPendingSnapshot = yield* Ref.make(true);
1054 const probeStarted = yield* Deferred.make<void>();
1055 const releaseProbe = yield* Deferred.make<void>();
1056 const makeInstance = (
1057 provider: ServerProvider,
1058 snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>,
1059 ): ProviderInstance => ({
1060 instanceId,
1061 driverKind: driver,
1062 continuationIdentity: {
1063 driverKind: driver,
1064 continuationKey: "codex:instance:codex",
1065 },
1066 displayName: undefined,
1067 enabled: true,
1068 snapshot: {
1069 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1070 provider: driver,
1071 packageName: null,
1072 }),
1073 getSnapshot: Effect.succeed(provider),
1074 refresh: Effect.succeed(provider),
1075 streamChanges: Stream.empty,
1076 },
1077 snapshotForCwd,
1078 adapter: {} as ProviderInstance["adapter"],
1079 textGeneration: {} as ProviderInstance["textGeneration"],
1080 });
1081 const firstInstance = makeInstance(machineProvider, () =>
1082 Effect.gen(function* () {
1083 yield* Ref.update(snapshotCalls, (count) => count + 1);
1084 if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider;
1085 yield* Deferred.succeed(probeStarted, undefined);
1086 yield* Deferred.await(releaseProbe);
1087 return scopedProvider;
1088 }),
1089 );
1090 const rebuiltProvider = {
1091 ...machineProvider,
1092 checkedAt: "2026-06-10T00:02:00.000Z",
1093 status: "warning",
1094 installed: false,
1095 auth: { status: "unknown" },
1096 } satisfies ServerProvider;
1097 const rebuiltInstance = makeInstance(rebuiltProvider, () =>
1098 Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)),
1099 );
1100 const registryChanges = yield* PubSub.unbounded<void>();
1101 const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]);
1102 const instanceRegistryLayer = Layer.succeed(
1103 ProviderInstanceRegistry.ProviderInstanceRegistry,
1104 {
1105 getInstance: (requestedId) =>
1106 Ref.get(instancesRef).pipe(
1107 Effect.map((instances) =>
1108 instances.find((instance) => instance.instanceId === requestedId),
1109 ),
1110 ),
1111 listInstances: Ref.get(instancesRef),
1112 listUnavailable: Effect.succeed([]),
1113 streamChanges: Stream.fromPubSub(registryChanges),
1114 subscribeChanges: PubSub.subscribe(registryChanges),
1115 },
1116 );
1117 const scope = yield* Scope.make();
1118 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1119 const runtimeServices = yield* Layer.build(
1120 ProviderRegistryLive.pipe(
1121 Layer.provideMerge(instanceRegistryLayer),
1122 Layer.provideMerge(
1123 ServerConfig.layerTest(process.cwd(), {
1124 prefix: "t3-provider-registry-workspace-snapshot-",
1125 }),
1126 ),
1127 Layer.provideMerge(NodeServices.layer),
1128 ),
1129 ).pipe(Scope.provide(scope));
1130
1131 yield* Effect.gen(function* () {
1132 const registry = yield* ProviderRegistry.ProviderRegistry;
1133 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1134 assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined);
1135 yield* Ref.set(returnPendingSnapshot, false);
1136 const workspaceUpdate = yield* registry.streamChanges.pipe(
1137 Stream.runHead,
1138 Effect.forkChild,
1139 );
1140 yield* Effect.yieldNow;
1141 const firstRefresh = yield* registry
1142 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1143 .pipe(Effect.forkChild);
1144 yield* Deferred.await(probeStarted);
1145 const duplicateRefresh = yield* registry
1146 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1147 .pipe(Effect.forkChild);
1148 yield* Effect.yieldNow;
1149 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1150 yield* Deferred.succeed(releaseProbe, undefined);
1151 yield* Fiber.join(firstRefresh);
1152 yield* Fiber.join(duplicateRefresh);
1153 const published = yield* Fiber.join(workspaceUpdate);
1154 assert.strictEqual(published._tag, "Some");
1155 const providers = yield* registry.getProviders;
1156 assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills);
1157 assert.deepStrictEqual(
1158 providers[0]?.workspaceSnapshots?.[0]?.skills,
1159 scopedProvider.skills,
1160 );
1161 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1162 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1163
1164 yield* Ref.set(instancesRef, [rebuiltInstance]);
1165 yield* PubSub.publish(registryChanges, undefined);
1166 let rebuilt = yield* registry.getProviders;
1167 for (
1168 let attempt = 0;
1169 attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt;
1170 attempt += 1
1171 ) {
1172 yield* Effect.yieldNow;
1173 rebuilt = yield* registry.getProviders;
1174 }
1175 assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt);
1176 assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined);
1177 }).pipe(Effect.provide(runtimeServices));
1178 }),
1179 );
1180
9861181 it.effect("refreshes OpenCode catalogs and preserves other providers", () =>
9871182 Effect.gen(function* () {
9881183 const codexDriver = ProviderDriverKind.make("codex");
apps/server/src/provider/Layers/ProviderRegistry.ts+118-1
......@@ -78,6 +78,28 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind)
7878const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
7979 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
8080
81const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16;
82
83export function upsertProviderWorkspaceSnapshot(
84 provider: ServerProvider,
85 cwd: string,
86 scopedSnapshot: ServerProvider,
87): ServerProvider {
88 const workspaceSnapshot = {
89 cwd,
90 checkedAt: scopedSnapshot.checkedAt,
91 slashCommands: scopedSnapshot.slashCommands,
92 skills: scopedSnapshot.skills,
93 } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];
94 return {
95 ...provider,
96 workspaceSnapshots: [
97 ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd),
98 workspaceSnapshot,
99 ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER),
100 };
101}
102
81103const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {
82104 if (provider.driver !== ProviderDriverKind.make("opencode")) {
83105 return true;
......@@ -140,6 +162,11 @@ export const mergeProviderSnapshot = (
140162 : {
141163 ...nextProvider,
142164 models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models),
165 ...(nextProvider.workspaceSnapshots !== undefined
166 ? { workspaceSnapshots: nextProvider.workspaceSnapshots }
167 : previousProvider.workspaceSnapshots !== undefined
168 ? { workspaceSnapshots: previousProvider.workspaceSnapshots }
169 : {}),
143170 ...(shouldRetainMissingOpenCodeMetadata(nextProvider)
144171 ? {
145172 slashCommands:
......@@ -283,6 +310,9 @@ export const ProviderRegistryLive = Layer.effect(
283310 ),
284311 );
285312 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
313 const workspaceRefreshesRef = yield* Ref.make<
314 ReadonlyMap<ProviderInstance, ReadonlySet<string>>
315 >(new Map());
286316 const maintenanceActionStatesRef = yield* Ref.make<
287317 ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
288318 >(new Map());
......@@ -315,7 +345,8 @@ export const ProviderRegistryLive = Layer.effect(
315345 cacheDir: config.providerStatusCacheDir,
316346 instanceId: key,
317347 }).pipe(Effect.provideService(Path.Path, path));
318 yield* writeProviderStatusCache({ filePath, provider }).pipe(
348 const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider;
349 yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe(
319350 Effect.provideService(FileSystem.FileSystem, fileSystem),
320351 Effect.provideService(Path.Path, path),
321352 Effect.tapError(Effect.logError),
......@@ -557,6 +588,28 @@ export const ProviderRegistryLive = Layer.effect(
557588 newlyAdded.push([instanceId, instance] as const);
558589 }
559590
591 const rebuiltInstanceIds = new Set(
592 newlyAdded
593 .map(([instanceId]) => instanceId)
594 .filter((instanceId) => previousSubs.has(instanceId)),
595 );
596 if (rebuiltInstanceIds.size > 0) {
597 const [previousProviders, providers] = yield* Ref.modify(
598 providersRef,
599 (previousProviders) => {
600 const providers = previousProviders.map((provider) => {
601 if (!rebuiltInstanceIds.has(provider.instanceId)) return provider;
602 const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider;
603 return machineSnapshot;
604 });
605 return [[previousProviders, providers] as const, providers];
606 },
607 );
608 if (haveProvidersChanged(previousProviders, providers)) {
609 yield* PubSub.publish(changesPubSub, providers);
610 }
611 }
612
560613 // Fork long-lived subscriptions to each new/rebuilt instance's
561614 // change stream before reading its current snapshot. If the
562615 // driver's own initial probe finishes during this sync, either
......@@ -698,12 +751,76 @@ export const ProviderRegistryLive = Layer.effect(
698751 return yield* Ref.get(providersRef);
699752 });
700753
754 const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: {
755 readonly instanceId: ProviderInstanceId;
756 readonly cwd: string;
757 }) {
758 const providers = yield* Ref.get(providersRef);
759 const provider = providers.find((candidate) => candidate.instanceId === input.instanceId);
760 if (
761 !provider ||
762 !provider.enabled ||
763 provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd)
764 ) {
765 return providers;
766 }
767 const instance = yield* instanceRegistry.getInstance(input.instanceId);
768 if (!instance?.snapshotForCwd) return providers;
769 const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => {
770 const current = refreshes.get(instance);
771 if (current?.has(input.cwd)) return [false, refreshes] as const;
772 const next = new Map(refreshes);
773 next.set(instance, new Set(current).add(input.cwd));
774 return [true, next] as const;
775 });
776 if (!claimed) return yield* Ref.get(providersRef);
777 return yield* instance.snapshotForCwd(input.cwd).pipe(
778 Effect.flatMap((scopedSnapshot) =>
779 scopedSnapshot.status === "error"
780 ? Ref.get(providersRef)
781 : instanceRegistry.getInstance(input.instanceId).pipe(
782 Effect.flatMap((currentInstance) => {
783 if (currentInstance !== instance) return Ref.get(providersRef);
784 return Ref.modify(providersRef, (currentProviders) => {
785 const nextProviders = currentProviders.map((candidate) =>
786 candidate.instanceId === input.instanceId &&
787 !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd)
788 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot)
789 : candidate,
790 );
791 return [[currentProviders, nextProviders] as const, nextProviders];
792 }).pipe(
793 Effect.tap(([previousProviders, nextProviders]) =>
794 haveProvidersChanged(previousProviders, nextProviders)
795 ? PubSub.publish(changesPubSub, nextProviders)
796 : Effect.void,
797 ),
798 Effect.map(([, nextProviders]) => nextProviders),
799 );
800 }),
801 ),
802 ),
803 Effect.ensuring(
804 Ref.update(workspaceRefreshesRef, (refreshes) => {
805 const next = new Map(refreshes);
806 const current = new Set(next.get(instance));
807 current.delete(input.cwd);
808 if (current.size) next.set(instance, current);
809 else next.delete(instance);
810 return next;
811 }),
812 ),
813 );
814 });
815
701816 return {
702817 getProviders: Ref.get(providersRef),
703818 refresh: (provider?: ProviderDriverKind) =>
704819 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
705820 refreshInstance: (instanceId: ProviderInstanceId) =>
706821 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
822 refreshWorkspaceSnapshot: (input) =>
823 refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)),
707824 getProviderMaintenanceCapabilitiesForInstance,
708825 setProviderMaintenanceActionState,
709826 get streamChanges() {
apps/server/src/provider/OpenCodeServerOwner.test.ts+8
......@@ -56,7 +56,9 @@ const makeRuntime = Effect.gen(function* () {
5656 runOpenCodeCommand: unusedRuntimeMethod,
5757 createOpenCodeSdkClient: () => ({}) as never,
5858 loadOpenCodeInventory: unusedRuntimeMethod,
59 loadOpenCodeSkills: unusedRuntimeMethod,
5960 loadInventoryFromCli: unusedRuntimeMethod,
61 loadSkillsFromCli: unusedRuntimeMethod,
6062 };
6163 return { runtime, starts, closes, failNextStart, started, closed };
6264});
......@@ -137,7 +139,9 @@ it.effect("invalidates an exited process so the next borrower starts a new one",
137139 runOpenCodeCommand: unusedRuntimeMethod,
138140 createOpenCodeSdkClient: () => ({}) as never,
139141 loadOpenCodeInventory: unusedRuntimeMethod,
142 loadOpenCodeSkills: unusedRuntimeMethod,
140143 loadInventoryFromCli: unusedRuntimeMethod,
144 loadSkillsFromCli: unusedRuntimeMethod,
141145 };
142146
143147 yield* Effect.scoped(
......@@ -183,7 +187,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () =>
183187 runOpenCodeCommand: unusedRuntimeMethod,
184188 createOpenCodeSdkClient: () => ({}) as never,
185189 loadOpenCodeInventory: unusedRuntimeMethod,
190 loadOpenCodeSkills: unusedRuntimeMethod,
186191 loadInventoryFromCli: unusedRuntimeMethod,
192 loadSkillsFromCli: unusedRuntimeMethod,
187193 };
188194
189195 yield* Effect.scoped(
......@@ -236,7 +242,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () =>
236242 runOpenCodeCommand: unusedRuntimeMethod,
237243 createOpenCodeSdkClient: () => ({}) as never,
238244 loadOpenCodeInventory: unusedRuntimeMethod,
245 loadOpenCodeSkills: unusedRuntimeMethod,
239246 loadInventoryFromCli: unusedRuntimeMethod,
247 loadSkillsFromCli: unusedRuntimeMethod,
240248 };
241249
242250 yield* Effect.scoped(
apps/server/src/provider/ProviderDriver.ts+2
......@@ -25,6 +25,7 @@ import type {
2525 ProviderDriverKind,
2626 ProviderInstanceEnvironment,
2727 ProviderInstanceId,
28 ServerProvider,
2829} from "@t3tools/contracts";
2930import type * as Effect from "effect/Effect";
3031import type * as Schema from "effect/Schema";
......@@ -69,6 +70,7 @@ export interface ProviderInstance {
6970 readonly accentColor?: string | undefined;
7071 readonly enabled: boolean;
7172 readonly snapshot: ServerProviderShape;
73 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
7274 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
7375 readonly textGeneration: TextGeneration.TextGeneration["Service"];
7476}
apps/server/src/provider/Services/ProviderRegistry.ts+5
......@@ -48,6 +48,11 @@ export interface ProviderRegistryShape {
4848 instanceId: ProviderInstanceId,
4949 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
5050
51 readonly refreshWorkspaceSnapshot: (input: {
52 readonly instanceId: ProviderInstanceId;
53 readonly cwd: string;
54 }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
55
5156 /**
5257 * Resolve the maintenance capabilities owned by one live provider instance.
5358 * Falls back to manual-only capabilities when the instance is not live.
apps/server/src/provider/opencodeRuntime.ts+34-3
......@@ -254,11 +254,19 @@ export interface OpenCodeRuntimeShape {
254254 readonly loadOpenCodeInventory: (
255255 client: OpencodeClient,
256256 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
257 readonly loadOpenCodeSkills: (
258 client: OpencodeClient,
259 ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;
257260 readonly loadInventoryFromCli: (input: {
258261 readonly binaryPath: string;
259262 readonly cwd: string;
260263 readonly environment?: NodeJS.ProcessEnv;
261264 }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
265 readonly loadSkillsFromCli: (input: {
266 readonly binaryPath: string;
267 readonly cwd: string;
268 readonly environment?: NodeJS.ProcessEnv;
269 }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;
262270}
263271
264272function parseServerUrlFromOutput(output: string): string | null {
......@@ -855,8 +863,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
855863 Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
856864 );
857865
858 const loadSkills = (client: OpencodeClient) =>
859 runOpenCodeSdk("app.skills", () => client.app.skills()).pipe(
866 const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) =>
867 runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe(
860868 Effect.map((result) =>
861869 (result.data ?? []).map((skill) => ({
862870 name: skill.name,
......@@ -864,8 +872,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
864872 location: skill.location,
865873 })),
866874 ),
867 Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []),
868875 );
876 const loadSkills = (client: OpencodeClient) =>
877 loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []));
869878
870879 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
871880 Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], {
......@@ -971,13 +980,35 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
971980 };
972981 });
973982
983 const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) =>
984 runOpenCodeCommand({
985 binaryPath: input.binaryPath,
986 args: ["debug", "skill"],
987 cwd: input.cwd,
988 maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES,
989 ...(input.environment !== undefined ? { environment: input.environment } : {}),
990 }).pipe(
991 Effect.flatMap((result) =>
992 result.code === 0
993 ? Effect.succeed(parseSkillsCliOutput(result.stdout))
994 : Effect.fail(
995 new OpenCodeRuntimeError({
996 operation: "loadSkillsFromCli",
997 detail: `OpenCode skills command exited with code ${result.code}.`,
998 }),
999 ),
1000 ),
1001 );
1002
9741003 return {
9751004 startOpenCodeServerProcess,
9761005 connectToOpenCodeServer,
9771006 runOpenCodeCommand,
9781007 createOpenCodeSdkClient,
9791008 loadOpenCodeInventory,
1009 loadOpenCodeSkills,
9801010 loadInventoryFromCli,
1011 loadSkillsFromCli,
9811012 } satisfies OpenCodeRuntimeShape;
9821013});
9831014
apps/server/src/provider/providerMaintenanceRunner.test.ts+1
......@@ -189,6 +189,7 @@ function makeRegistry(
189189 getProviders: Ref.get(providersRef),
190190 refresh: () => Ref.get(providersRef),
191191 refreshInstance: () => Ref.get(providersRef),
192 refreshWorkspaceSnapshot: () => Ref.get(providersRef),
192193 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
193194 Effect.succeed(lifecycleFor(provider)),
194195 setProviderMaintenanceActionState,
apps/server/src/provider/testUtils/providerRegistryMock.ts+1
......@@ -11,6 +11,7 @@ export const makeProviderRegistryMock = (
1111 getProviders: Effect.succeed(providers),
1212 refresh: () => Effect.succeed(providers),
1313 refreshInstance: () => Effect.succeed(providers),
14 refreshWorkspaceSnapshot: () => Effect.succeed(providers),
1415 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
1516 Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })),
1617 setProviderMaintenanceActionState: () => Effect.succeed(providers),
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+2
......@@ -136,6 +136,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
136136 cause: null,
137137 }),
138138 ),
139 loadOpenCodeSkills: () => Effect.succeed([]),
139140 loadInventoryFromCli: () =>
140141 Effect.fail(
141142 new OpenCodeRuntime.OpenCodeRuntimeError({
......@@ -144,6 +145,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
144145 cause: null,
145146 }),
146147 ),
148 loadSkillsFromCli: () => Effect.succeed([]),
147149};
148150
149151const DEFAULT_TEST_MODEL_SELECTION = {
apps/server/src/ws.ts+8-3
......@@ -1662,9 +1662,14 @@ const makeWsRpcLayer = (
16621662 [WS_METHODS.serverRefreshProviders]: (input) =>
16631663 observeRpcEffect(
16641664 WS_METHODS.serverRefreshProviders,
1665 (input.instanceId !== undefined
1666 ? providerRegistry.refreshInstance(input.instanceId)
1667 : providerRegistry.refresh()
1665 (input.cwd !== undefined && input.instanceId !== undefined
1666 ? providerRegistry.refreshWorkspaceSnapshot({
1667 instanceId: input.instanceId,
1668 cwd: input.cwd,
1669 })
1670 : input.instanceId !== undefined
1671 ? providerRegistry.refreshInstance(input.instanceId)
1672 : providerRegistry.refresh()
16681673 ).pipe(Effect.map((providers) => ({ providers }))),
16691674 { "rpc.aggregate": "server" },
16701675 ),
apps/web/src/components/ChatView.tsx+6-1
......@@ -278,6 +278,7 @@ import {
278278 requestOlderThreadTurns,
279279 threadHasOlderTurns,
280280} from "@t3tools/client-runtime/state/threads";
281import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
281282import { vcsEnvironment } from "../state/vcs";
282283import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
283284import {
......@@ -7303,7 +7304,11 @@ function ChatViewContent(props: ChatViewProps) {
73037304 resolvedTheme={resolvedTheme}
73047305 timestampFormat={timestampFormat}
73057306 workspaceRoot={activeWorkspaceRoot}
7306 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
7307 skills={
7308 activeProviderStatus
7309 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
7310 : EMPTY_PROVIDER_SKILLS
7311 }
73077312 anchorMessageId={timelineAnchorMessageId}
73087313 onAnchorReady={onTimelineAnchorReady}
73097314 contentInsetEndAdjustment={composerOverlayHeight}
apps/web/src/components/chat/ChatComposer.tsx+72-16
......@@ -334,9 +334,13 @@ import {
334334 formatProviderSkillDisplayName,
335335 getProviderSlashCommandsForSlashMenu,
336336 getProviderSkillsForSlashMenu,
337 resolveProviderSkillsForCwd,
338 resolveProviderSlashCommandsForCwd,
337339} from "@t3tools/client-runtime/providerSkills";
338340import { searchProviderSkills } from "../../providerSkillSearch";
339341import { useMediaQuery } from "../../hooks/useMediaQuery";
342import { useAtomCommand } from "../../state/use-atom-command";
343import { serverEnvironment } from "../../state/server";
340344import type { ReviewCommentContext } from "../../reviewCommentContext";
341345
342346const runtimeModeConfig: Record<
......@@ -1113,6 +1117,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11131117 () => selectedProviderEntry?.snapshot ?? null,
11141118 [selectedProviderEntry],
11151119 );
1120 const selectedProviderSkills = selectedProviderStatus
1121 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
1122 : [];
1123 const selectedProviderSlashCommands = selectedProviderStatus
1124 ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd)
1125 : [];
1126 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
1127 reportFailure: false,
1128 });
1129 const workspaceRefreshKeyRef = useRef<string | null>(null);
1130 const hadWorkspaceSnapshotRef = useRef(false);
1131 useEffect(() => {
1132 const hasWorkspaceSnapshot = Boolean(
1133 gitCwd &&
1134 selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd),
1135 );
1136 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
1137 workspaceRefreshKeyRef.current = null;
1138 }
1139 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
1140 }, [gitCwd, selectedProviderStatus]);
1141 useEffect(() => {
1142 if (!gitCwd || !selectedProviderEntry) return;
1143 const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
1144 const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some(
1145 (snapshot) => snapshot.cwd === gitCwd,
1146 );
1147 if (workspaceRefreshKeyRef.current === key) return;
1148 if (hasWorkspaceSnapshot) {
1149 workspaceRefreshKeyRef.current = key;
1150 return;
1151 }
1152 workspaceRefreshKeyRef.current = key;
1153 void refreshProviders({
1154 environmentId,
1155 input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd },
1156 }).then(
1157 (result) => {
1158 const hasWorkspaceSnapshot =
1159 result._tag === "Success" &&
1160 result.value.providers
1161 .find((provider) => provider.instanceId === selectedProviderEntry.instanceId)
1162 ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd);
1163 if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {
1164 workspaceRefreshKeyRef.current = null;
1165 }
1166 },
1167 () => {
1168 if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null;
1169 },
1170 );
1171 }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]);
11161172 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
11171173 () => selectedProviderEntry?.models ?? [],
11181174 [selectedProviderEntry],
......@@ -1340,11 +1396,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13401396 : []),
13411397 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
13421398 const slashMenuSkills = getProviderSkillsForSlashMenu(
1343 selectedProviderStatus?.skills ?? [],
1399 selectedProviderSkills,
13441400 settings.showSkillsInSlashMenu,
13451401 );
13461402 const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(
1347 selectedProviderStatus?.slashCommands ?? [],
1403 selectedProviderSlashCommands,
13481404 slashMenuSkills,
13491405 ).map((command) => ({
13501406 id: `provider-slash-command:${selectedProvider}:${command.name}`,
......@@ -1373,25 +1429,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13731429 return searchSlashCommandItems(slashCommandItems, query);
13741430 }
13751431 if (composerTrigger.kind === "skill") {
1376 return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(
1377 (skill) => ({
1378 id: `skill:${selectedProvider}:${skill.name}`,
1379 type: "skill" as const,
1380 provider: selectedProvider,
1381 skill,
1382 label: formatProviderSkillDisplayName(skill),
1383 description:
1384 skill.shortDescription ??
1385 skill.description ??
1386 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
1387 }),
1388 );
1432 return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({
1433 id: `skill:${selectedProvider}:${skill.name}`,
1434 type: "skill" as const,
1435 provider: selectedProvider,
1436 skill,
1437 label: formatProviderSkillDisplayName(skill),
1438 description:
1439 skill.shortDescription ??
1440 skill.description ??
1441 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
1442 }));
13891443 }
13901444 return [];
13911445 }, [
13921446 composerTrigger,
13931447 planModeUiEnabled,
13941448 selectedProvider,
1449 selectedProviderSkills,
1450 selectedProviderSlashCommands,
13951451 selectedProviderStatus,
13961452 settings.showSkillsInSlashMenu,
13971453 workspaceEntries.entries,
......@@ -4096,7 +4152,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40964152 ? composerTerminalContexts
40974153 : []
40984154 }
4099 skills={selectedProviderStatus?.skills ?? []}
4155 skills={selectedProviderSkills}
41004156 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
41014157 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
41024158 onChange={onPromptChange}
packages/client-runtime/src/providerSkills.test.ts+41
......@@ -1,3 +1,4 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
23
34import {
......@@ -5,9 +6,33 @@ import {
56 formatProviderSkillDisplayName,
67 getProviderSlashCommandsForSlashMenu,
78 getProviderSkillsForSlashMenu,
9 resolveProviderSkillsForCwd,
10 resolveProviderSlashCommandsForCwd,
811 resolveProviderSkillSourceKind,
912} from "./providerSkills.ts";
1013
14const provider = {
15 instanceId: ProviderInstanceId.make("codex"),
16 driver: ProviderDriverKind.make("codex"),
17 enabled: true,
18 installed: true,
19 version: "1.0.0",
20 status: "ready",
21 auth: { status: "authenticated" },
22 checkedAt: "2026-01-01T00:00:00.000Z",
23 models: [],
24 slashCommands: [{ name: "global" }],
25 skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
26 workspaceSnapshots: [
27 {
28 cwd: "/workspace/project-a",
29 checkedAt: "2026-01-01T00:01:00.000Z",
30 slashCommands: [{ name: "project" }],
31 skills: [{ name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }],
32 },
33 ],
34} satisfies ServerProvider;
35
1136describe("formatProviderSkillDisplayName", () => {
1237 it("prefers the provider display name", () => {
1338 expect(
......@@ -211,3 +236,19 @@ describe("resolveProviderSkillSourceKind", () => {
211236 ).toBe("other");
212237 });
213238});
239
240describe("workspace provider snapshots", () => {
241 it("uses the cwd snapshot after a provider session has populated it", () => {
242 expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([
243 { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true },
244 ]);
245 expect(resolveProviderSlashCommandsForCwd(provider, "/workspace/project-a")).toEqual([
246 { name: "project" },
247 ]);
248 });
249
250 it("keeps the machine snapshot before this cwd has a provider snapshot", () => {
251 expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills);
252 expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands);
253 });
254});
packages/client-runtime/src/providerSkills.ts+27-1
......@@ -1,4 +1,8 @@
1import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
1import type {
2 ServerProvider,
3 ServerProviderSkill,
4 ServerProviderSlashCommand,
5} from "@t3tools/contracts";
26
37export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
48
......@@ -98,3 +102,25 @@ export function resolveProviderSkillSourceKind(
98102 return "other";
99103 }
100104}
105
106function resolveProviderWorkspaceSnapshot(
107 provider: ServerProvider,
108 cwd: string | null | undefined,
109) {
110 if (!cwd) return undefined;
111 return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd);
112}
113
114export function resolveProviderSkillsForCwd(
115 provider: ServerProvider,
116 cwd: string | null | undefined,
117): ServerProvider["skills"] {
118 return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills;
119}
120
121export function resolveProviderSlashCommandsForCwd(
122 provider: ServerProvider,
123 cwd: string | null | undefined,
124): ServerProvider["slashCommands"] {
125 return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands;
126}
packages/client-runtime/src/state/server.ts+2-1
......@@ -849,7 +849,8 @@ export function createServerEnvironmentAtoms<R, E>(
849849 tag: WS_METHODS.serverRefreshProviders,
850850 concurrency: {
851851 mode: "singleFlight",
852 key: ({ environmentId }) => environmentId,
852 key: ({ environmentId, input }) =>
853 JSON.stringify([environmentId, input.instanceId ?? null, input.cwd ?? null]),
853854 },
854855 }),
855856 updateProvider: createEnvironmentRpcCommand(runtime, {
packages/contracts/src/rpc.ts+2
......@@ -1,6 +1,7 @@
11import * as Schema from "effect/Schema";
22import * as Rpc from "effect/unstable/rpc/Rpc";
33import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
4import { TrimmedNonEmptyString } from "./baseSchemas.ts";
45
56import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";
67import {
......@@ -368,6 +369,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
368369 * refreshes.
369370 */
370371 instanceId: Schema.optional(ProviderInstanceId),
372 cwd: Schema.optional(TrimmedNonEmptyString),
371373 }),
372374 success: ServerProviderUpdatedPayload,
373375 error: EnvironmentAuthorizationError,
packages/contracts/src/server.ts+9
......@@ -110,6 +110,14 @@ export const ServerProviderSkill = Schema.Struct({
110110});
111111export type ServerProviderSkill = typeof ServerProviderSkill.Type;
112112
113export const ServerProviderWorkspaceSnapshot = Schema.Struct({
114 cwd: TrimmedNonEmptyString,
115 checkedAt: IsoDateTime,
116 slashCommands: Schema.Array(ServerProviderSlashCommand),
117 skills: Schema.Array(ServerProviderSkill),
118});
119export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;
120
113121/**
114122 * Availability of a configured provider instance from the runtime's POV.
115123 *
......@@ -206,6 +214,7 @@ export const ServerProvider = Schema.Struct({
206214 Schema.withDecodingDefault(Effect.succeed([])),
207215 ),
208216 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
217 workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)),
209218 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
210219 updateState: Schema.optionalKey(ServerProviderUpdateState),
211220});