Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
ServerProviderSkill,
} from "@t3tools/contracts";
import {
detectComposerTrigger,
Expand Down Expand Up @@ -106,6 +107,7 @@ export interface ThreadComposerProps {
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
readonly skills: ReadonlyArray<ServerProviderSkill>;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftImages: () => Promise<void>;
Expand Down Expand Up @@ -431,7 +433,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

if (composerTrigger.kind === "skill") {
const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
const enabledSkills = props.skills.filter((s) => s.enabled);
const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
trimLeadingPattern: /^\$+/,
});
Expand Down Expand Up @@ -528,7 +530,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

return [];
}, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
}, [composerTrigger, pathSearch.entries, props.skills, selectedProviderStatus]);

// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
Expand Down Expand Up @@ -775,7 +777,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
ref={inputRef}
multiline
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
skills={props.skills}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onSelectionChange={handleSelectionChange}
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import type {
PendingUserInputDraftAnswer,
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { useWorkspaceSkills } from "../../state/queries";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
Expand Down Expand Up @@ -445,11 +446,18 @@ 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 workspaceSkills = useWorkspaceSkills({
environmentId: props.environmentId,
instanceId: selectedInstanceId,
cwd: props.threadCwd,
});
const selectedProviderSkills = useMemo(
() =>
workspaceSkills.skills ??
props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
?.skills ?? [],
[props.serverConfig, selectedInstanceId],
?.skills ??
[],
[props.serverConfig, selectedInstanceId, workspaceSkills.skills],
);

useLayoutEffect(() => {
Expand Down Expand Up @@ -729,6 +737,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
activeThreadBusy={props.activeThreadBusy}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
skills={selectedProviderSkills}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
updateComposerDraftSettings,
useComposerDraft,
} from "../../state/use-composer-drafts";
import { useBranches } from "../../state/queries";
import { useBranches, useWorkspaceSkills } from "../../state/queries";
import {
flattenQueuedThreadMessages,
threadOutboxManager,
Expand Down Expand Up @@ -436,12 +436,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
option.selection.instanceId === selectedModel.instanceId &&
option.selection.model === selectedModel.model,
) ?? null;
const workspaceSkills = useWorkspaceSkills({
environmentId: selectedProject?.environmentId ?? null,
instanceId: selectedModel?.instanceId ?? null,
cwd: selectedProject?.workspaceRoot || null,
});
const selectedProviderSkills = useMemo(
() =>
workspaceSkills.skills ??
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === selectedModel?.instanceId,
)?.skills ?? [],
[selectedEnvironmentServerConfig, selectedModel?.instanceId],
)?.skills ??
[],
[selectedEnvironmentServerConfig, selectedModel?.instanceId, workspaceSkills.skills],
);
const setSelectedModelKey = useCallback(
// Options ride along in the same write: a follow-up setSelectedModelOptions
Expand Down
38 changes: 37 additions & 1 deletion apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationThread,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import {
createThreadSearchResultsAtomFamily,
makeThreadSearchKey,
Expand All @@ -12,6 +17,7 @@ import { useEffect, useMemo, useState } from "react";
import { orchestrationEnvironment } from "./orchestration";
import { projectEnvironment } from "./projects";
import { useEnvironmentQuery } from "./query";
import { serverEnvironment } from "./server";
import { useEnvironmentThread } from "./threads";
import { vcsEnvironment } from "./vcs";
import {
Expand Down Expand Up @@ -125,6 +131,36 @@ export function useBranches(input: {
);
}

/**
* Skills for one workspace; `null` while nothing is known, hence the `??` at
* call sites. An empty array is an answer and is deliberately kept.
*/
export function useWorkspaceSkills(input: {
readonly environmentId: EnvironmentId | null;
readonly instanceId: ProviderInstanceId | null;
readonly cwd: string | null;
}) {
// A stand-in project carries an empty workspaceRoot, which the schema rejects.
const cwd = input.cwd?.trim() ? input.cwd : null;
const result = useEnvironmentQuery(
input.environmentId !== null && cwd !== null
? serverEnvironment.workspaceSkills({
environmentId: input.environmentId,
input: {
...(input.instanceId ? { instanceId: input.instanceId } : {}),
cwd,
},
})
: null,
);

return {
skills: result.data?.skills ?? null,
error: result.error,
isPending: result.isPending,
};
}

export function useComposerPathSearch(target: ComposerPathSearchTarget) {
const normalizedTarget = useMemo(
() => ({
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
[WS_METHODS.serverListWorkspaceSkills]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { makeWorkspaceSkillsCache } from "../workspaceSkills.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
Expand Down Expand Up @@ -163,6 +165,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);

const listWorkspaceSkills = yield* makeWorkspaceSkillsCache((workspaceCwd) =>
discoverClaudeSkills(effectiveConfig, workspaceCwd, processEnv).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
);

const checkProvider = checkClaudeProviderStatus(
effectiveConfig,
() => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
Expand Down Expand Up @@ -216,6 +225,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
snapshot,
adapter,
textGeneration,
listWorkspaceSkills,
} satisfies ProviderInstance;
}),
};
32 changes: 26 additions & 6 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,31 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*

const skillsByName = new Map<string, ServerProviderSkill>();
for (const root of roots) {
const entries = yield* fileSystem
.readDirectory(root.directory)
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
// Debug, not warning: a missing skills directory is the common case. Still
// logged, so an unreadable one is not silently an empty picker.
const entries = yield* fileSystem.readDirectory(root.directory).pipe(
Effect.tapError((cause) =>
Effect.logDebug("claude skill root is not readable", {
directory: root.directory,
scope: root.scope,
cause,
}),
),
Effect.orElseSucceed((): ReadonlyArray<string> => []),
);

for (const entry of [...entries].sort()) {
const skillPath = path.join(root.directory, entry, "SKILL.md");
const contents = yield* fileSystem
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
const contents = yield* fileSystem.readFileString(skillPath).pipe(
Effect.tapError((cause) =>
Effect.logDebug("claude skill entry has no readable SKILL.md", {
path: skillPath,
scope: root.scope,
cause,
}),
),
Effect.orElseSucceed(() => undefined),
);
if (contents === undefined) {
continue;
}
Expand All @@ -124,6 +140,10 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
// either — skip it rather than surfacing a broken entry under its
// directory name.
if (frontmatter.kind === "malformed") {
yield* Effect.logWarning("claude skill has malformed YAML frontmatter; skipping", {
path: skillPath,
scope: root.scope,
});
continue;
}

Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ 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,
listCodexWorkspaceSkills,
makePendingCodexProvider,
} from "../Layers/CodexProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import { makeWorkspaceSkillsCache } from "../workspaceSkills.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
makePackageManagedProviderMaintenanceResolver,
Expand Down Expand Up @@ -170,6 +175,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const listWorkspaceSkills = yield* makeWorkspaceSkillsCache((workspaceCwd) =>
listCodexWorkspaceSkills(effectiveConfig, workspaceCwd, processEnv).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
maintenanceCapabilities,
Expand Down Expand Up @@ -208,6 +219,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
snapshot,
adapter,
textGeneration,
listWorkspaceSkills,
} satisfies ProviderInstance;
}),
};
68 changes: 66 additions & 2 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,12 +319,15 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
};
}

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
/**
* Spawn `codex app-server` and complete its initialize handshake. Closing the
* caller's scope tears the child process down.
*/
const openCodexAppServerClient = Effect.fn("openCodexAppServerClient")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
}) {
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
Expand Down Expand Up @@ -385,6 +388,19 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
const version = versionMatch ? versionMatch[1] : undefined;

return { client, version } as const;
});

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
}) {
const { client, version } = yield* openCodexAppServerClient(input);

const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return {
Expand Down Expand Up @@ -415,6 +431,54 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
} satisfies CodexAppServerProviderSnapshot;
});

const requestCodexWorkspaceSkills = Effect.fn("requestCodexWorkspaceSkills")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
}) {
const { client } = yield* openCodexAppServerClient(input);
const response = yield* client.request("skills/list", { cwds: [input.cwd] });
return parseCodexSkillsListResponse(response, input.cwd);
});

/**
* List the skills Codex would load for one workspace directory. Answering
* needs a live app-server, so unlike Claude's filesystem scan this can yield
* `undefined` ("could not look") rather than `[]`.
*/
export const listCodexWorkspaceSkills = (
codexSettings: CodexSettings,
cwd: string,
environment?: NodeJS.ProcessEnv,
): Effect.Effect<
ReadonlyArray<ServerProviderSkill> | undefined,
never,
ChildProcessSpawner.ChildProcessSpawner
> => {
if (!codexSettings.enabled) {
return Effect.succeed([]);
}
const resolvedEnvironment = environment ?? process.env;
return requestCodexWorkspaceSkills({
binaryPath: codexSettings.binaryPath,
homePath: codexSettings.homePath,
launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
cwd,
environment: resolvedEnvironment,
}).pipe(
Effect.scoped,
// A typed timeout, not `timeoutOption`, so a slow app-server is logged and
// reported like a spawn failure rather than as "no skills here".
Effect.timeout(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
Effect.tapError((cause) =>
Effect.logDebug("codex workspace skill discovery failed", { cwd, cause }),
),
Effect.orElseSucceed((): ReadonlyArray<ServerProviderSkill> | undefined => undefined),
);
Comment thread
cursor[bot] marked this conversation as resolved.
};

const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
const models = new Set<string>();
for (const model of codexSettings.customModels) {
Expand Down
Loading
Loading