Skip to content
Open
2 changes: 2 additions & 0 deletions .macroscope/check-run-agents/effect-service-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ 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.
Expand Down
1 change: 1 addition & 0 deletions .macroscope/check-run-agents/ui-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 16 additions & 5 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ import {
normalizeSearchQuery,
scoreQueryMatch,
} from "@t3tools/shared/searchRanking";
import {
resolveProviderSkillsForCwd,
resolveProviderSlashCommandsForCwd,
} from "@t3tools/client-runtime/providerSkills";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
Expand Down Expand Up @@ -355,6 +359,13 @@ 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, workspaceSnapshotCwd)
: [];
const selectedProviderSlashCommands = selectedProviderStatus
? resolveProviderSlashCommandsForCwd(selectedProviderStatus, workspaceSnapshotCwd)
: [];
Comment thread
cursor[bot] marked this conversation as resolved.

// ── Trigger detection ────────────────────────────────────
const [composerSelection, setComposerSelection] = useState(() => ({
Expand Down Expand Up @@ -420,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}`,
Expand All @@ -431,7 +442,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}`,
Expand All @@ -445,7 +456,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: /^\$+/,
});
Expand Down Expand Up @@ -542,7 +553,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

return [];
}, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
}, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderSlashCommands]);

// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
Expand Down Expand Up @@ -797,7 +808,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
ref={inputRef}
multiline
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
Comment thread
cursor[bot] marked this conversation as resolved.
skills={selectedProviderSkills}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onSelectionChange={handleSelectionChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -392,7 +393,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,
Expand Down Expand Up @@ -501,6 +504,7 @@ describe("ProviderCommandReactor", () => {
stopSession,
renameBranch,
refreshStatus,
refreshWorkspaceSnapshot,
generateBranchName,
generateThreadTitle,
runtimeSessions,
Expand Down Expand Up @@ -536,6 +540,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",
Expand All @@ -545,6 +550,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"));
Expand Down Expand Up @@ -1906,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);
});
Expand Down
31 changes: 21 additions & 10 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,21 +604,31 @@ 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;
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(() => refreshWorkspaceSnapshot));

const bindSessionToThread = (session: ProviderSession) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -676,6 +686,7 @@ const make = Effect.gen(function* () {
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange
) {
yield* refreshWorkspaceSnapshot;
return existingSessionThreadId;
}

Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
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<ProviderSnapshotSettings<ClaudeSettings>>({
Expand Down Expand Up @@ -214,6 +229,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
accentColor,
enabled,
snapshot,
snapshotForCwd,
adapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
23 changes: 19 additions & 4 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,24 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
// 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,
}),
),
);
const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
maintenanceCapabilities,
Expand Down Expand Up @@ -206,6 +220,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
accentColor,
enabled,
snapshot,
snapshotForCwd,
adapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
17 changes: 10 additions & 7 deletions apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
});
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<ProviderSnapshotSettings<CursorSettings>>({
Expand Down Expand Up @@ -181,6 +183,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
accentColor,
enabled,
snapshot,
snapshotForCwd: checkProviderForCwd,
adapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
13 changes: 8 additions & 5 deletions apps/server/src/provider/Drivers/GrokDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,13 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
});
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<ProviderSnapshotSettings<GrokSettings>>({
Expand Down Expand Up @@ -156,6 +158,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
accentColor,
enabled,
snapshot,
snapshotForCwd: checkProviderForCwd,
adapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
12 changes: 7 additions & 5 deletions apps/server/src/provider/Drivers/OpenCodeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
});
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<ProviderSnapshotSettings<OpenCodeSettings>>(
Expand Down Expand Up @@ -187,6 +188,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
accentColor,
enabled,
snapshot,
snapshotForCwd: checkProviderForCwd,
adapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading