Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
45 changes: 43 additions & 2 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
import {
checkCodexProviderStatus,
makePendingCodexProvider,
probeCodexAppServerProvider,
} 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 { AUTH_PROBE_TIMEOUT_MS, type ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
Expand Down Expand Up @@ -161,6 +165,42 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
});
const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv);
const listSkills = Effect.fn("CodexDriver.listSkills")(function* (cwd: string) {
if (!effectiveConfig.enabled) {
return [];
}

return yield* probeCodexAppServerProvider({
Comment thread
jakeleventhal marked this conversation as resolved.
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
cwd,
customModels: [],
environment: processEnv,
includeModels: false,
}).pipe(
Effect.scoped,
Comment thread
jakeleventhal marked this conversation as resolved.
Effect.map((result) => result.skills),
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to list Codex skills: ${cause.message}`,
cause,
}),
),
Effect.timeoutOrElse({
duration: Duration.millis(AUTH_PROBE_TIMEOUT_MS),
orElse: () =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Timed out listing Codex skills after ${AUTH_PROBE_TIMEOUT_MS}ms`,
}),
}),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
});
Comment thread
cursor[bot] marked this conversation as resolved.

// Build a managed snapshot whose settings never change — mutations come
// in as instance rebuilds from the registry rather than in-place
Expand Down Expand Up @@ -209,6 +249,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
snapshot,
adapter,
textGeneration,
listSkills,
} satisfies ProviderInstance;
}),
};
165 changes: 85 additions & 80 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,94 +286,99 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
};
}

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
readonly binaryPath: string;
readonly homePath?: 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`,
// so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
// "CODEX_HOME points to '~/.codex_work', but that path does not exist".
// Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`.
const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = {
...input.environment,
...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
};
const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], {
env: environment,
extendEnv: true,
});
const child = yield* spawner
.spawn(
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
cwd: input.cwd,
env: environment,
extendEnv: true,
forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
shell: spawnCommand.shell,
}),
)
.pipe(
Effect.mapError(
(cause) =>
new CodexErrors.CodexAppServerSpawnError({
command: `${input.binaryPath} app-server`,
cause,
}),
),
export const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(
function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
readonly includeModels?: boolean;
}) {
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
// so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
// "CODEX_HOME points to '~/.codex_work', but that path does not exist".
// Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`.
const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = {
...input.environment,
...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
};
const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], {
env: environment,
extendEnv: true,
});
const child = yield* spawner
.spawn(
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
cwd: input.cwd,
env: environment,
extendEnv: true,
forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
shell: spawnCommand.shell,
}),
)
.pipe(
Effect.mapError(
(cause) =>
new CodexErrors.CodexAppServerSpawnError({
command: `${input.binaryPath} app-server`,
cause,
}),
),
);
const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
Effect.provide(clientContext),
);
const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
Effect.provide(clientContext),
);

const initialize = yield* client.request("initialize", {
clientInfo: {
name: "t3code_desktop",
title: "T3 Code Desktop",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
},
});
yield* client.notify("initialized", undefined);
const initialize = yield* client.request("initialize", {
clientInfo: {
name: "t3code_desktop",
title: "T3 Code Desktop",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
},
});
yield* client.notify("initialized", undefined);

// Extract the version string after the first '/' in userAgent, up to the next space or the end
const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
const version = versionMatch ? versionMatch[1] : undefined;

const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return {
account: accountResponse,
version,
models: appendCustomCodexModels([], input.customModels ?? []),
skills: [],
} satisfies CodexAppServerProviderSnapshot;
}

// Extract the version string after the first '/' in userAgent, up to the next space or the end
const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
const version = versionMatch ? versionMatch[1] : undefined;
let skillsResponse: CodexSchema.V2SkillsListResponse;
let models: ReadonlyArray<ServerProviderModel>;
if (input.includeModels === false) {
skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
models = [];
} else {
[skillsResponse, models] = yield* Effect.all(
[client.request("skills/list", { cwds: [input.cwd] }), requestAllCodexModels(client)],
{ concurrency: "unbounded" },
);
}

const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return {
account: accountResponse,
version,
models: appendCustomCodexModels([], input.customModels ?? []),
skills: [],
models: appendCustomCodexModels(models, input.customModels ?? []),
skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
} satisfies CodexAppServerProviderSnapshot;
}

const [skillsResponse, models] = yield* Effect.all(
[
client.request("skills/list", {
cwds: [input.cwd],
}),
requestAllCodexModels(client),
],
{ concurrency: "unbounded" },
);

return {
account: accountResponse,
version,
models: appendCustomCodexModels(models, input.customModels ?? []),
skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
} satisfies CodexAppServerProviderSnapshot;
});
},
);

const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
const models = new Set<string>();
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
skills: [],
} as const satisfies ServerProvider;
const refreshCalls = yield* Ref.make(0);
const listedSkillCwds = yield* Ref.make<ReadonlyArray<string>>([]);
const projectSkill = {
name: "project-review",
path: "/tmp/project/.codex/skills/project-review/SKILL.md",
enabled: true,
scope: "repo",
} as const;
const instance = {
instanceId: codexInstanceId,
driverKind: codexDriver,
Expand All @@ -635,6 +642,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
},
adapter: {} as ProviderInstance["adapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
listSkills: (cwd: string) =>
Ref.update(listedSkillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([projectSkill])),
} satisfies ProviderInstance;
const instanceRegistryLayer = Layer.succeed(
ProviderInstanceRegistry.ProviderInstanceRegistry,
Expand Down Expand Up @@ -664,6 +673,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const registry = yield* ProviderRegistry.ProviderRegistry;
assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]);
assert.strictEqual(yield* Ref.get(refreshCalls), 0);
assert.deepStrictEqual(
yield* registry.listSkills({ instanceId: codexInstanceId, cwd: "/tmp/project" }),
[projectSkill],
);
assert.deepStrictEqual(yield* Ref.get(listedSkillCwds), ["/tmp/project"]);
}).pipe(Effect.provide(runtimeServices));
}),
);
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,19 @@ export const ProviderRegistryLive = Layer.effect(
);
});

const listSkills = Effect.fn("ProviderRegistry.listSkills")(function* (input: {
readonly instanceId: ProviderInstanceId;
readonly cwd: string;
}) {
const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find(
(candidate) => candidate.instanceId === input.instanceId,
);
if (!instance?.listSkills) {
return undefined;
}
return yield* instance.listSkills(input.cwd);
});

/**
* Diff the aggregator's live-source set against the current
* `ProviderInstanceRegistry` and:
Expand Down Expand Up @@ -688,6 +701,7 @@ export const ProviderRegistryLive = Layer.effect(
refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshInstance: (instanceId: ProviderInstanceId) =>
refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
listSkills,
getProviderMaintenanceCapabilitiesForInstance,
setProviderMaintenanceActionState,
get streamChanges() {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/ProviderDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
ProviderDriverKind,
ProviderInstanceEnvironment,
ProviderInstanceId,
ServerProviderSkill,
} from "@t3tools/contracts";
import type * as Effect from "effect/Effect";
import type * as Schema from "effect/Schema";
Expand Down Expand Up @@ -71,6 +72,9 @@ export interface ProviderInstance {
readonly snapshot: ServerProviderShape;
readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
readonly textGeneration: TextGeneration.TextGeneration["Service"];
readonly listSkills?: (
cwd: string,
) => Effect.Effect<ReadonlyArray<ServerProviderSkill>, ProviderDriverError>;
}

export interface ProviderContinuationIdentity {
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/Services/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,23 @@ import type {
ProviderInstanceId,
ProviderDriverKind,
ServerProvider,
ServerProviderSkill,
ServerProviderUpdateState,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import type * as Effect from "effect/Effect";
import type * as Stream from "effect/Stream";
import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
import type { ProviderDriverError } from "../Errors.ts";

export type ProviderMaintenanceActionKind = "update";

export interface ProviderRegistryShape {
readonly listSkills: (input: {
readonly instanceId: ProviderInstanceId;
readonly cwd: string;
}) => Effect.Effect<ReadonlyArray<ServerProviderSkill> | undefined, ProviderDriverError>;

/**
* Read the latest provider snapshots for every configured instance.
* Multiple snapshots may share the same `provider` kind (multiple
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ function makeRegistry(
getProviders: Ref.get(providersRef),
refresh: () => Ref.get(providersRef),
refreshInstance: () => Ref.get(providersRef),
listSkills: () => Effect.sync(() => undefined),
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
Effect.succeed(lifecycleFor(provider)),
setProviderMaintenanceActionState,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/testUtils/providerRegistryMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const makeProviderRegistryMock = (
getProviders: Effect.succeed(providers),
refresh: () => Effect.succeed(providers),
refreshInstance: () => Effect.succeed(providers),
listSkills: () => Effect.sync(() => undefined),
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })),
setProviderMaintenanceActionState: () => Effect.succeed(providers),
Expand Down
Loading
Loading