1import {
2 type EnvironmentId,
3 isProviderWorkspaceSnapshotCurrent,
4 isProviderWorkspaceSnapshotPending,
5 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
6 type ProviderInstanceId,
7 type ServerProvider,
8 type ServerProviderSkill,
9 type ServerProviderSlashCommand,
10} from "@t3tools/contracts";
11import { Atom } from "effect/reactivity";
12
13import { deadlineStore } from "./clock.ts";
14import { type AtomCommand, runAtomCommand } from "./state/runtime.ts";
15
16export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
17
18function normalizePathSeparators(pathValue: string): string {
19 return pathValue.replaceAll("\\", "/");
20}
21
22export function dedupeProviderSkillsByName(
23 skills: ReadonlyArray<ServerProviderSkill>,
24): ServerProviderSkill[] {
25 const seenNames = new Set<string>();
26 return skills.filter((skill) => {
27 const normalizedName = skill.name.trim().toLowerCase();
28 if (seenNames.has(normalizedName)) {
29 return false;
30 }
31 seenNames.add(normalizedName);
32 return true;
33 });
34}
35
36/**
37 * Exclude disabled and agent-only skills (Claude Code's `user-invocable: false`).
38 * Skills the agent cannot start on its own can still be invoked by users in the provider's native form.
39 */
40export function isProviderSkillUserInvocable(
41 skill: Pick<ServerProviderSkill, "enabled" | "userInvocable">,
42): boolean {
43 return skill.enabled && skill.userInvocable !== false;
44}
45
46export function getProviderSkillsForSlashMenu(
47 skills: ReadonlyArray<ServerProviderSkill>,
48 showSkillsInSlashMenu: boolean,
49): ServerProviderSkill[] {
50 return showSkillsInSlashMenu
51 ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable))
52 : [];
53}
54
55export function getProviderSlashCommandsForSlashMenu(
56 slashCommands: ReadonlyArray<ServerProviderSlashCommand>,
57 visibleSkills: ReadonlyArray<ServerProviderSkill>,
58): ServerProviderSlashCommand[] {
59 const skillNames = new Set(visibleSkills.map((skill) => skill.name.trim().toLowerCase()));
60 return slashCommands.filter((command) => !skillNames.has(command.name.trim().toLowerCase()));
61}
62
63export function resolveProviderSkillSourceKind(
64 skill: Pick<ServerProviderSkill, "path" | "scope">,
65): ProviderSkillSourceKind {
66 const normalizedPath = normalizePathSeparators(skill.path);
67 if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) {
68 return "app";
69 }
70
71 const normalizedScope = skill.scope?.trim().toLowerCase();
72 switch (normalizedScope) {
73 case "repo":
74 case "repository":
75 return "repo";
76 case "project":
77 case "workspace":
78 case "local":
79 return "project";
80 case "user":
81 case "personal":
82 return "personal";
83 case "system":
84 return "system";
85 case undefined:
86 case "":
87 return "other";
88 default:
89 return "other";
90 }
91}
92
93function resolveProviderWorkspaceSnapshot(
94 provider: ServerProvider,
95 cwd: string | null | undefined,
96) {
97 if (!cwd) return undefined;
98 return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd);
99}
100
101export function hasCompleteProviderWorkspaceSnapshot(
102 provider: ServerProvider | null | undefined,
103 cwd: string | null | undefined,
104): boolean {
105 const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd);
106 return Boolean(snapshot && !isProviderWorkspaceSnapshotPending(snapshot));
107}
108
109function hasCurrentProviderWorkspaceSnapshot(
110 provider: ServerProvider | null | undefined,
111 cwd: string | null | undefined,
112 nowMs: number,
113): boolean {
114 const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd);
115 return Boolean(
116 snapshot &&
117 !isProviderWorkspaceSnapshotPending(snapshot) &&
118 isProviderWorkspaceSnapshotCurrent(snapshot, nowMs),
119 );
120}
121
122export function resolveProviderSkillsForCwd(
123 provider: ServerProvider,
124 cwd: string | null | undefined,
125): ServerProvider["skills"] {
126 return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills;
127}
128
129export function resolveProviderSlashCommandsForCwd(
130 provider: ServerProvider,
131 cwd: string | null | undefined,
132): ServerProvider["slashCommands"] {
133 return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands;
134}
135
136/** A project's own provider config can add models that only its workspace snapshot lists. */
137function resolveProviderModelsForCwd(
138 provider: ServerProvider,
139 cwd: string | null | undefined,
140): ServerProvider["models"] {
141 return resolveProviderWorkspaceSnapshot(provider, cwd)?.models ?? provider.models;
142}
143
144/** Providers keep their identity unless the cwd has its own catalog. */
145export function resolveProvidersForCwd<P extends ServerProvider>(
146 providers: ReadonlyArray<P>,
147 cwd: string | null | undefined,
148): ReadonlyArray<P> {
149 const resolved = providers.map((provider) => {
150 const models = resolveProviderModelsForCwd(provider, cwd);
151 return models === provider.models ? provider : { ...provider, models };
152 });
153 return resolved.every((provider, index) => provider === providers[index]) ? providers : resolved;
154}
155
156const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
157// A cwd that never scans, such as a deleted worktree, would otherwise retry forever.
158const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3;
159
160export interface ProviderWorkspaceSnapshotRefreshState {
161 /**
162 * When the last scan was requested. A request inside the TTL is not
163 * repeated, so a client clock ahead of the server's cannot loop rescans.
164 */
165 readonly requestedAt: number | null;
166 /** A failed scan is not retried before this time. */
167 readonly retryNotBefore: number | null;
168 /** Failed scans within the current request TTL. */
169 readonly failedAttempts: number;
170 readonly hadSnapshot: boolean;
171 /** The server published a partial snapshot and is still discovering. */
172 readonly snapshotPending: boolean;
173}
174
175const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRefreshState = {
176 requestedAt: null,
177 retryNotBefore: null,
178 failedAttempts: 0,
179 hadSnapshot: false,
180 snapshotPending: false,
181};
182
183/**
184 * Decides whether a composer looking at `cwd` should ask the server to rescan
185 * the provider's workspace, and when to look again if a retry is due while the
186 * server still reports the scan as pending.
187 */
188function planProviderWorkspaceSnapshotRefresh(
189 previous: ProviderWorkspaceSnapshotRefreshState,
190 provider: ServerProvider | null | undefined,
191 cwd: string,
192 now: number,
193): {
194 readonly state: ProviderWorkspaceSnapshotRefreshState;
195 readonly refresh: boolean;
196 readonly recheckAt: number | null;
197} {
198 const hasSnapshot = hasCompleteProviderWorkspaceSnapshot(provider, cwd);
199 const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd);
200 const pending = Boolean(snapshot && isProviderWorkspaceSnapshotPending(snapshot));
201 // Losing a snapshot (the server dropped it) lifts both the TTL and the retry
202 // cooldown so the scan happens at once.
203 const lost = previous.hadSnapshot && !hasSnapshot;
204 const requestedAt = lost ? null : previous.requestedAt;
205 const retryNotBefore =
206 lost || (previous.retryNotBefore !== null && now >= previous.retryNotBefore)
207 ? null
208 : previous.retryNotBefore;
209 const state = {
210 requestedAt,
211 retryNotBefore,
212 failedAttempts:
213 lost || (requestedAt !== null && now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS)
214 ? 0
215 : previous.failedAttempts,
216 hadSnapshot: hasSnapshot,
217 snapshotPending: pending,
218 };
219 const refresh =
220 (requestedAt === null || now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) &&
221 !hasCurrentProviderWorkspaceSnapshot(provider, cwd, now) &&
222 retryNotBefore === null;
223 if (refresh) {
224 return { state: { ...state, requestedAt: now }, refresh: true, recheckAt: null };
225 }
226 return { state, refresh: false, recheckAt: pending ? retryNotBefore : null };
227}
228
229/**
230 * A scan that failed, or finished without a complete snapshot, cools down
231 * before retrying. After the last attempt the request marker stays, so only
232 * the TTL (or a snapshot loss) lets the next scan through.
233 */
234function failProviderWorkspaceSnapshotRefresh(
235 state: ProviderWorkspaceSnapshotRefreshState,
236 requestedAt: number,
237 now: number,
238): ProviderWorkspaceSnapshotRefreshState {
239 // A newer request, or a snapshot loss, already superseded this one.
240 if (state.requestedAt !== requestedAt) return state;
241 const failedAttempts = state.failedAttempts + 1;
242 if (failedAttempts >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return { ...state, failedAttempts };
243 return {
244 ...state,
245 requestedAt: null,
246 retryNotBefore: now + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
247 failedAttempts,
248 };
249}
250
251/**
252 * A scan is in flight, or the server is still discovering, for a cwd without a
253 * complete snapshot, so its skills and commands may still arrive. A failed scan
254 * clears `requestedAt`, and exhausted attempts end it, so this cannot stay true
255 * for a scan that will not finish.
256 */
257export function isProviderWorkspaceSnapshotScanning(
258 state: ProviderWorkspaceSnapshotRefreshState | undefined,
259): boolean {
260 return (
261 state !== undefined &&
262 (state.requestedAt !== null || state.snapshotPending) &&
263 !state.hadSnapshot &&
264 state.failedAttempts < WORKSPACE_SNAPSHOT_MAX_ATTEMPTS
265 );
266}
267
268type WorkspaceSnapshotStatus = "absent" | "pending" | "complete";
269
270function resolveWorkspaceSnapshotStatus(
271 provider: ServerProvider | undefined,
272 cwd: string,
273): WorkspaceSnapshotStatus {
274 const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd);
275 if (!snapshot) return "absent";
276 return isProviderWorkspaceSnapshotPending(snapshot) ? "pending" : "complete";
277}
278
279interface ProviderWorkspaceSnapshotRefreshTarget {
280 readonly environmentId: EnvironmentId;
281 readonly instanceId: ProviderInstanceId;
282 readonly cwd: string;
283}
284
285/**
286 * Keeps one environment/provider/cwd workspace snapshot fresh while mounted:
287 * reading scans when it is missing or stale, and refreshing re-checks (the
288 * composer does as the user types). It owns the retry timer, so the timer dies
289 * with the last subscriber.
290 */
291export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: {
292 readonly providers: (
293 environmentId: EnvironmentId,
294 ) => Atom.Atom<ReadonlyArray<ServerProvider> | null>;
295 readonly refreshProviders: AtomCommand<
296 {
297 readonly environmentId: EnvironmentId;
298 readonly input: { readonly instanceId: ProviderInstanceId; readonly cwd: string };
299 },
300 { readonly providers: ReadonlyArray<ServerProvider> },
301 unknown
302 >;
303}) {
304 // Plain-object keys compare structurally (Effect `Equal`/`Hash`).
305 const family = Atom.family((target: ProviderWorkspaceSnapshotRefreshTarget) => {
306 const { environmentId, instanceId, cwd } = target;
307 const key = `${environmentId}:${instanceId}:${cwd}`;
308 const findProvider = (providers: ReadonlyArray<ServerProvider> | null) =>
309 providers?.find((provider) => provider.instanceId === instanceId);
310 const statusAtom = Atom.make((get) =>
311 resolveWorkspaceSnapshotStatus(findProvider(get(input.providers(environmentId))), cwd),
312 ).pipe(Atom.withLabel(`provider-workspace-snapshot-status:${key}`));
313 type Update = (
314 state: ProviderWorkspaceSnapshotRefreshState,
315 ) => ProviderWorkspaceSnapshotRefreshState;
316 const refreshAtom: Atom.Writable<ProviderWorkspaceSnapshotRefreshState, Update> = Atom.writable(
317 (get): ProviderWorkspaceSnapshotRefreshState => {
318 get(statusAtom);
319 // `once`: unrelated provider updates must not trigger a check.
320 const provider = findProvider(get.once(input.providers(environmentId)));
321 // @effect-diagnostics-next-line globalDate:off
322 const now = Date.now();
323 const previous = get.self<ProviderWorkspaceSnapshotRefreshState>();
324 const plan = planProviderWorkspaceSnapshotRefresh(
325 previous._tag === "Some" ? previous.value : initialProviderWorkspaceSnapshotRefreshState,
326 provider,
327 cwd,
328 now,
329 );
330 if (plan.recheckAt !== null) {
331 get.addFinalizer(deadlineStore(plan.recheckAt).subscribe(() => get.refreshSelf()));
332 }
333 if (!plan.refresh) return plan.state;
334 const registry = get.registry;
335 // Outlives this build on purpose: a rebuild must not drop the retry.
336 // Once nothing subscribes, the retry state dies with the node; writing
337 // would recreate and rebuild it, which scans again with no subscriber.
338 const retryLater = () => {
339 const node = registry.getNodes().get(refreshAtom);
340 if (node === undefined || node.canBeRemoved) return;
341 registry.set(refreshAtom, (state) =>
342 // @effect-diagnostics-next-line globalDate:off
343 failProviderWorkspaceSnapshotRefresh(state, now, Date.now()),
344 );
345 };
346 void runAtomCommand(
347 registry,
348 input.refreshProviders,
349 { environmentId, input: { instanceId, cwd } },
350 { reportFailure: false },
351 ).then((result) => {
352 const refreshedProvider =
353 result._tag === "Success" ? findProvider(result.value.providers) : undefined;
354 const snapshot =
355 refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd);
356 // A new server timestamp proves progress even when the client clock is ahead.
357 // @effect-diagnostics-next-line globalDate:off
358 const completedAt = Date.now();
359 const refreshed =
360 hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) &&
361 (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) ||
362 snapshot?.checkedAt !==
363 (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt);
364 if (!refreshed) retryLater();
365 }, retryLater);
366 return plan.state;
367 },
368 (ctx, update: Update) => {
369 ctx.setSelf(update(ctx.get(refreshAtom)));
370 ctx.refreshSelf();
371 },
372 ).pipe(Atom.withLabel(`provider-workspace-snapshot-refresh:${key}`));
373 return refreshAtom;
374 });
375 return (
376 target: ProviderWorkspaceSnapshotRefreshTarget,
377 ): Atom.Atom<ProviderWorkspaceSnapshotRefreshState> =>
378 family({ environmentId: target.environmentId, instanceId: target.instanceId, cwd: target.cwd });
379}