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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 78.1 KB | 4 permissions | Listed |
No comments yet.