Official

codex

Codex agent provider: runs the Codex CLI's app-server for each account.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/codex@0.2.0

Permissions in 0.2.0

  • Provide agents agents.provideMediumAdds agents to the app.Provide the Codex agent and pass its tool calls to plugin tools
  • Run named programs processMediumStarts the listed programs.Run the Codex CLI, and ask or tell the installer that owns it (npm or Homebrew) about a newer versionPrograms: codexnpmbrew
  • Environment variables envMediumReads the listed environment variables.Find each account's Codex home, and pass extra app-server arguments set in CODEX_ARGSVariables: HOMECODEX_HOMECODEX_ARGS
  • Read files fs.readMediumReads files in the listed places.Read, once, the accounts the previous Codex provider keptPlaces: its own data folder

Files

params.ts11.3 KB
// What the plugin sends Codex: the config options it offers, the settings
// of `thread/start`, `thread/resume`, `thread/fork` and `turn/start`, and
// the prompt as Codex's `UserInput` blocks. Pure functions of their input.
import type { Model, Config } from "./wire.ts";
import type { SessionOptions, PromptInput, Choice, ConfigOption } from "./types.ts";

import { permissionMode } from "../sdk/agent.ts";

/// Option ids. The values are the wire values Codex expects, so a chosen
/// value can go straight into `thread/start` and `turn/start`.
export const OPTION_MODEL = "model";
export const OPTION_REASONING = "reasoning";
export const OPTION_SERVICE_TIER = "service_tier";

/// A chosen option's string value, when it has one.
export function pick(options: Record<string, unknown> | undefined, key: string) {
  const value = options?.[key];
  return typeof value === "string" && value ? value : null;
}

export function titleCase(value: unknown) {
  const text = String(value ?? "");
  if (!text) return "";
  const [first, ...rest] = Array.from(text);
  return (first ?? "").toUpperCase() + rest.join("");
}

/// The shared permission mode that matches the user's own Codex settings
/// (`config/read`, snake_case keys), used until the chat chooses one.
export function modeOf(config: Config | null | undefined) {
  const policy = typeof config?.approval_policy === "string" ? config.approval_policy : "";
  const sandbox = typeof config?.sandbox_mode === "string" ? config.sandbox_mode : "";
  if (policy === "never" && sandbox === "danger-full-access") return permissionMode.FULL;
  if (policy === "on-request") return permissionMode.AUTO_EDITS;
  return permissionMode.SUPERVISED;
}

/// How one of the four shared permission modes reaches Codex.
///
/// `approvalsReviewer` is always sent: omitting it on resume keeps the
/// thread's previous reviewer, so `auto_review` would stay switched on
/// after the user moved back to a stricter mode.
export function permissions(mode: string) {
  switch (mode) {
    case permissionMode.SUPERVISED:
      return { approvalPolicy: "untrusted", sandbox: "read-only", reviewer: "user" };
    case permissionMode.AUTO_EDITS:
      return { approvalPolicy: "on-request", sandbox: "workspace-write", reviewer: "user" };
    case permissionMode.AUTO:
      return { approvalPolicy: "on-request", sandbox: "workspace-write", reviewer: "auto_review" };
    // `permissionMode.selected` never returns anything but the four.
    default:
      return { approvalPolicy: "never", sandbox: "danger-full-access", reviewer: "user" };
  }
}

/// `turn/start` takes a structured sandbox policy where `thread/start`
/// takes the mode string.
export function sandboxPolicy(mode: string, workspace: string) {
  if (mode === "read-only") return { type: "readOnly", networkAccess: false };
  if (mode === "danger-full-access") return { type: "dangerFullAccess" };
  return {
    type: "workspaceWrite",
    writableRoots: [workspace],
    networkAccess: false,
    excludeTmpdirEnvVar: false,
    excludeSlashTmp: false,
  };
}

/// The settings `thread/start`, `thread/resume` and `thread/fork` share.
///
/// The permissions are always explicit (see `permissions`), and
/// `developerInstructions` is sent every time: Codex rebuilds the context
/// from the current settings when it compacts, so a resumed thread that
/// was not given them again would lose the rules at its next compaction.
function threadParams<T extends Record<string, unknown>>(
  params: T,
  session: SessionOptions,
): T & Record<string, unknown> {
  const values: Record<string, unknown> = params;
  const model = pick(session.options, OPTION_MODEL);
  if (model) values.model = model;
  const chosen = permissions(permissionMode.selected(session.options));
  values.approvalPolicy = chosen.approvalPolicy;
  values.sandbox = chosen.sandbox;
  values.approvalsReviewer = chosen.reviewer;
  if (typeof session.instructions === "string") values.developerInstructions = session.instructions;
  return params;
}

/// `thread/start` params. The plugin tools go here only: Codex stores
/// them with the thread and ignores `dynamicTools` on resume and fork, so
/// a thread keeps the tools it started with.
export function startParams(session: SessionOptions) {
  const params = threadParams({ cwd: session.workspace ?? "" }, session);
  const tier = pick(session.options, OPTION_SERVICE_TIER);
  if (tier) params.serviceTier = tier;
  const tools = Array.isArray(session.tools) ? session.tools : [];
  if (tools.length) {
    params.dynamicTools = tools.map((tool) => ({
      type: "function",
      name: tool.name,
      description: tool.description ?? "",
      inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
      deferLoading: false,
    }));
  }
  return params;
}

export function resumeParams(threadId: string, session: SessionOptions) {
  const params = threadParams({ threadId, cwd: session.workspace ?? "", excludeTurns: true }, session);
  const tier = pick(session.options, OPTION_SERVICE_TIER);
  if (tier) params.serviceTier = tier;
  return params;
}

export function forkParams(threadId: string, session: SessionOptions) {
  return threadParams({ threadId, cwd: session.workspace ?? "", excludeTurns: true }, session);
}

/// `turn/start` params: the input and the chosen settings, sent with every
/// turn so a change mid-session takes effect on the next prompt.
export function turnParams(threadId: string, input: unknown[], workspace: string, options: Record<string, unknown>) {
  const params: Record<string, unknown> = { threadId, input };
  const values = params;
  const model = pick(options, OPTION_MODEL);
  if (model) values.model = model;
  const effort = pick(options, OPTION_REASONING);
  if (effort) params.effort = effort;
  const chosen = permissions(permissionMode.selected(options));
  values.approvalPolicy = chosen.approvalPolicy;
  params.sandboxPolicy = sandboxPolicy(chosen.sandbox, workspace);
  values.approvalsReviewer = chosen.reviewer;
  const tier = pick(options, OPTION_SERVICE_TIER);
  if (tier) params.serviceTier = tier;
  return params;
}

function baseName(path: string) {
  const parts = String(path).split(/[\\/]/).filter(Boolean);
  return parts.length ? parts[parts.length - 1] : String(path);
}

/// The prompt as Codex's `UserInput` blocks. `skills` maps a skill name
/// to the path Codex knows it by, which a `skill` block needs.
export function userInput(blocks: PromptInput["blocks"], skills: Map<string, string> = new Map()) {
  return (Array.isArray(blocks) ? blocks : []).map((block) => {
    switch (block?.type) {
      // Codex takes a skill as its own input block, not as `/name`. An
      // unknown skill would be dropped as a block, so it goes through as
      // the text the user typed.
      case "skill": {
        const path = skills.get(block.name ?? "");
        if (path) return { type: "skill", name: block.name, path };
        return { type: "text", text: `$${block.name} ${block.input ?? ""}`.trimEnd(), text_elements: [] };
      }
      case "resource":
        return { type: "text", text: `${block.path}:\n${block.text}`, text_elements: [] };
      case "audio":
        return { type: "audio", url: `data:${block.mimeType};base64,${block.data}` };
      case "image":
        return { type: "image", url: `data:${block.mimeType};base64,${block.data}` };
      case "file_ref":
        return { type: "mention", name: baseName(block.path ?? ""), path: block.path };
      default:
        return { type: "text", text: String(block?.text ?? ""), text_elements: [] };
    }
  });
}

function effortChoices(model: Model | null | undefined) {
  return (Array.isArray(model?.supportedReasoningEfforts) ? model.supportedReasoningEfforts : []).map((effort) => {
    const choice: Choice = { value: effort.reasoningEffort, name: titleCase(effort.reasoningEffort) };
    if (effort.description) choice.description = effort.description;
    return choice;
  });
}

/// The speed tiers of a model, after Standard (`default`), which resets a
/// thread's tier to normal speed, including after a Fast turn. Empty for a
/// model that offers no extra tiers.
export function speedChoices(model: Model | null | undefined) {
  const tiers = Array.isArray(model?.serviceTiers) ? model.serviceTiers : [];
  if (!tiers.length) return [];
  return [
    { value: "default", name: "Standard" },
    ...tiers.map((tier) => {
      const choice: Choice = { value: tier.id, name: tier.name ? tier.name : titleCase(tier.id) };
      if (tier.description) choice.description = tier.description;
      return choice;
    }),
  ];
}

/// The config options for a session: the model catalog (each model with
/// its own reasoning levels), the reasoning of the chosen model, the
/// permission mode and, for a model with speed tiers, the speed. Values
/// come from the chat's choices (`overrides`), then the user's Codex
/// config, then the catalog's defaults.
export function buildOptions(
  models: Model[],
  config: Config | null | undefined,
  overrides: Record<string, unknown> = {},
) {
  const modelValue =
    pick(overrides, OPTION_MODEL) ??
    (typeof config?.model === "string" && config.model ? config.model : null) ??
    models.find((model) => model.isDefault)?.id ??
    models[0]?.id ??
    "";
  const selected = models.find((model) => model.id === modelValue) ?? null;
  const efforts = selected ? effortChoices(selected) : [];
  const valid = (value: string | null) => (value && efforts.some((effort) => effort.value === value) ? value : null);
  const reasoningValue =
    valid(pick(overrides, OPTION_REASONING)) ??
    valid(typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null) ??
    (typeof selected?.defaultReasoningEffort === "string" ? selected.defaultReasoningEffort : null) ??
    efforts[0]?.value ??
    "";

  const tiers = speedChoices(selected);
  const validTier = (value: string | null) => (value && tiers.some((tier) => tier.value === value) ? value : null);
  const tierValue =
    validTier(pick(overrides, OPTION_SERVICE_TIER)) ??
    validTier(typeof selected?.defaultServiceTier === "string" ? selected.defaultServiceTier : null) ??
    "default";

  const options: ConfigOption[] = [
    {
      id: OPTION_MODEL,
      name: "Model",
      category: "model",
      kind: "select",
      value: modelValue,
      choices: models.map((model) => {
        const choice: Choice = { value: model.id, name: model.displayName ? model.displayName : model.id };
        if (model.description) choice.description = model.description;
        if (typeof model.modelSpecialty === "string") choice.group = model.modelSpecialty;
        const levels = effortChoices(model);
        if (levels.length) choice.reasoningLevels = levels;
        return choice;
      }),
    },
    {
      id: OPTION_REASONING,
      name: "Reasoning",
      category: "reasoning",
      kind: "select",
      value: reasoningValue,
      choices: efforts,
    },
    // Codex has its own approval reviewer, so Auto is a real mode here
    // rather than a synonym of Supervised. A chat that has not chosen yet
    // starts from the user's own Codex configuration.
    permissionMode.option(
      typeof overrides?.[permissionMode.OPTION_ID] === "string" ? overrides[permissionMode.OPTION_ID] : modeOf(config),
      true,
    ),
  ];
  if (tiers.length) {
    options.push({
      id: OPTION_SERVICE_TIER,
      name: "Speed",
      category: OPTION_SERVICE_TIER,
      kind: "select",
      value: tierValue,
      choices: tiers,
    });
  }
  return options;
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <378.1 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.