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
map.ts32.6 KB
// Pure mapping from Codex app-server items to Convergence protocol values.
//
// Everything here is a function of its input, so the whole native-to-event
// translation is tested without a Codex process. Codex's JSON is read
// leniently (every field may be missing), as the Rust types read it with
// serde defaults; an item type this plugin does not know maps to nothing,
// so a newer Codex never breaks a turn.
import * as z from "zod";
import * as wire from "./wire.ts";
import type { Item, Thread, Turn, Elicitation } from "./wire.ts";
import type {
QuestionField,
QuestionOption,
QuestionAnswer,
ToolCall,
ToolContent,
TranscriptItem,
History,
Task,
UsageLimits,
UsageWindow,
} from "./types.ts";
import type { ToolResult } from "../sdk/agent.ts";
import type { ContentBlock, Plan, QuestionRequest, TaskStatus, UsageRecovery } from "convergence/protocol";
type Action = z.infer<typeof wire.action>;
type Change = z.infer<typeof wire.change>;
type Property = z.infer<typeof wire.property> & { items?: z.infer<typeof wire.property> | null };
interface ChildNote {
spawn: { item: string; prompt: string | null; model: string | null; effort: string | null } | null;
status: TaskStatus | null;
message: string | null;
path: string | null;
}
/// The item types this plugin reads. Any other type is unknown.
const ITEM_TYPES = new Set([
"userMessage",
"agentMessage",
"reasoning",
"plan",
"commandExecution",
"fileChange",
"mcpToolCall",
"dynamicToolCall",
"webSearch",
"imageView",
"contextCompaction",
"collabAgentToolCall",
"subAgentActivity",
]);
const str = (value: unknown) => (typeof value === "string" ? value : "");
const opt = (value: unknown) => (typeof value === "string" ? value : null);
const arr = <T>(value: readonly T[] | null | undefined): readonly T[] => (Array.isArray(value) ? value : []);
/// The item's type, or `null` for one this plugin does not know.
export function itemType(item: Item | null | undefined) {
return item && ITEM_TYPES.has(item.type ?? "") ? item.type : null;
}
/// The item's id, or `null` for an unknown item.
export function itemId(item: Item | null | undefined) {
return itemType(item) ? str(item?.id) : null;
}
// --- threads (types.rs) ------------------------------------------------------
/// `source.subAgent.thread_spawn`: a thread another agent thread started.
/// Review, compaction and guardian threads are subagents too, but not
/// spawned ones. The app-server writes `subAgent`, rollout files
/// `subagent`.
export function threadSpawn(thread: Thread | null | undefined) {
const source = thread?.source;
if (!source || typeof source !== "object") return null;
const sub = source.subAgent ?? source.subagent;
const spawn = sub && typeof sub === "object" ? sub.thread_spawn : null;
if (!spawn || typeof spawn !== "object" || typeof spawn.parent_thread_id !== "string") return null;
return {
parentThreadId: spawn.parent_thread_id,
agentNickname: opt(spawn.agent_nickname),
agentRole: opt(spawn.agent_role),
agentPath: opt(spawn.agent_path),
};
}
/// The parent thread of a spawned subagent.
export function threadParent(thread: Thread | null | undefined) {
const direct = opt(thread?.parentThreadId);
if (direct) return direct;
return threadSpawn(thread)?.parentThreadId ?? null;
}
export function threadNickname(thread: Thread | null | undefined) {
const name = opt(thread?.agentNickname) ?? threadSpawn(thread)?.agentNickname ?? null;
return name ? name : null;
}
/// The task name a v2 parent gave it, the last segment of its path.
export function threadTaskName(thread: Thread | null | undefined) {
const path = threadSpawn(thread)?.agentPath;
return path ? pathTaskName(path) : null;
}
export function threadRole(thread: Thread | null | undefined) {
const role = opt(thread?.agentRole) ?? threadSpawn(thread)?.agentRole ?? null;
return role ? role : null;
}
/// `thread/status/changed`: a thread that is active with flags waits on
/// the user.
export function threadWaiting(status: z.infer<typeof wire.status> | null | undefined) {
return Array.isArray(status?.activeFlags) && status.activeFlags.length > 0;
}
// --- questions ------------------------------------------------------------------
/// Async assistant questions (`delivery: "async"`) are answered by a
/// normal follow-up message.
export function backgroundQuestion(item: Item | null | undefined): Omit<QuestionRequest, "message"> | null {
if (
item?.type !== "agentMessage" ||
item.delivery !== "async" ||
!Array.isArray(item.questions) ||
!item.questions.length
) {
return null;
}
return {
id: `async:${str(item?.id)}`,
responseMode: "message",
fields: item.questions.map((question, index) => {
const options = arr(question?.options)
.filter((label) => typeof label === "string")
.map((label) => ({ value: String(label), label: String(label) }));
const field: QuestionField = {
id: String(index),
label: str(question?.title),
kind: options.length ? "select" : "text",
allowOther: true,
required: true,
};
if (options.length) field.options = options;
return field;
}),
};
}
/// One Convergence field per property of an MCP elicitation schema.
export function elicitationFields(schema: Elicitation | null | undefined) {
const properties = schema?.properties;
if (!properties || typeof properties !== "object" || Array.isArray(properties)) return [];
const required = new Set(arr(schema?.required).filter((id) => typeof id === "string"));
return Object.entries(properties).map(([id, property]) => {
const options = elicitationOptions(property);
const type = property?.type;
const kind =
type === "boolean" ? "boolean" : type === "array" ? "multi_select" : options.length ? "select" : "text";
const field: QuestionField = {
id,
label: typeof property?.title === "string" ? property.title : id,
kind,
allowOther: false,
required: required.has(id),
};
if (typeof property?.description === "string") field.description = property.description;
if (options.length) field.options = options;
return field;
});
}
function elicitationOptions(property: Property | null | undefined) {
const source = property?.items ?? property;
if (Array.isArray(source?.oneOf)) {
return source.oneOf
.filter((entry) => typeof entry?.const === "string")
.map((entry) => {
const option: QuestionOption = {
value: String(entry.const),
label: typeof entry.title === "string" ? entry.title : String(entry.const),
};
if (typeof entry.description === "string") option.description = entry.description;
return option;
});
}
if (!Array.isArray(source?.enum)) return [];
const names = arr(source.enumNames);
return source.enum.flatMap((value, index) =>
typeof value === "string"
? [{ value, label: typeof names[index] === "string" ? String(names[index]) : value }]
: [],
);
}
/// Question inputs are strings; MCP number fields must go back as JSON
/// numbers or schema validation rejects an otherwise valid answer.
export function elicitationContent(
schema: Elicitation | null | undefined,
values: NonNullable<QuestionAnswer["values"]>,
) {
const properties = schema?.properties ?? {};
const content = { ...values };
for (const [id, value] of Object.entries(content)) {
if (typeof value !== "string") continue;
const type = properties[id]?.type;
const text = value.trim();
if (type === "integer" && /^[+-]?\d+$/.test(text)) content[id] = Number.parseInt(text, 10);
else if (type === "number" && text !== "" && Number.isFinite(Number(text))) content[id] = Number(text);
}
return content;
}
// --- tools ----------------------------------------------------------------------
/// Classifies a shell command from Codex's own `commandActions`, never
/// from the command string. A command is a read or a search only when
/// every parsed action is one.
export function commandToolKind(actions: readonly Action[] | null | undefined) {
const list = arr(actions);
if (!list.length) return "execute";
if (list.every((action) => action?.type === "read")) return "read";
const readonly = list.every((action) => ["read", "listFiles", "search"].includes(action?.type ?? ""));
return readonly ? "search" : "execute";
}
export function toolStatus(status: unknown) {
switch (status) {
case "completed":
return "completed";
case "failed":
return "failed";
case "declined":
return "cancelled";
default:
return "running";
}
}
/// Paths touched by a command, for the `locations` of a read or search.
function commandLocations(actions: readonly Action[] | null | undefined) {
const out = [];
for (const action of arr(actions)) {
let path = null;
if (action?.type === "read") path = str(action.path);
else if (action?.type === "listFiles" || action?.type === "search") path = opt(action.path);
if (path) out.push({ path });
}
return out;
}
function changeTitle(changes: readonly Change[]) {
if (!changes.length) return "Edit files";
if (changes.length === 1) return str(changes[0]?.path);
return `${str(changes[0]?.path)} and ${changes.length - 1} more`;
}
/// The command without the `/bin/zsh -lc '…'` wrapper Codex runs it in:
/// the row names what the agent ran, not how the shell was invoked.
export function unwrapped(command: string) {
const first = command.indexOf(" ");
if (first < 0) return command;
const second = command.indexOf(" ", first + 1);
if (second < 0) return command;
const shellPath = command.slice(0, first);
const flag = command.slice(first + 1, second);
const rest = command.slice(second + 1).trim();
const shell = shellPath.split("/").pop() || shellPath;
if (!["zsh", "bash", "sh"].includes(shell) || !["-lc", "-c"].includes(flag)) return command;
for (const quote of ["'", '"']) {
if (rest.length >= 2 && rest.startsWith(quote) && rest.endsWith(quote)) return rest.slice(1, -1);
}
return rest;
}
/// A tool call in the protocol's shape: `input`, `content` and
/// `locations` are left out when they are empty, as the host writes them.
export function toolCall({
id,
name,
kind,
title,
status,
input = null,
content = [],
locations = [],
}: ToolCall): ToolCall {
const call: ToolCall = { id, name, kind, title, status };
if (input !== null && input !== undefined) call.input = input;
if (content.length) call.content = content;
if (locations.length) call.locations = locations;
return call;
}
/// Each change as a diff the app can draw and count. Codex sends a new or
/// a deleted file whole, not as a patch: it becomes the file's text after
/// or before.
export function diffContent(changes: readonly Change[]): ToolContent[] {
return changes.map((change) => {
const path = str(change?.path);
const body = str(change?.diff);
switch (change?.kind?.type) {
case "add":
return { type: "diff", path, oldText: "", newText: body };
case "delete":
return { type: "diff", path, oldText: body, newText: "" };
default:
return { type: "diff", path, diff: body };
}
});
}
/// The tool call for an item that renders as one, or `null` for items
/// that are messages, reasoning or bookkeeping.
export function toolCallFromItem(item: Item | null | undefined): ToolCall | null {
if (!item) return null;
switch (itemType(item)) {
case "commandExecution": {
const command = str(item.command);
const cwd = opt(item.cwd);
const terminal: ToolContent = { type: "terminal", command, output: str(item.aggregatedOutput) };
if (cwd !== null) terminal.cwd = cwd;
if (Number.isInteger(item.exitCode)) terminal.exitCode = item.exitCode ?? undefined;
return toolCall({
id: str(item?.id),
name: "shell",
kind: commandToolKind(item.commandActions),
title: unwrapped(command),
status: toolStatus(item.status),
input: { command, cwd },
content: [terminal],
locations: commandLocations(item.commandActions),
});
}
case "fileChange": {
const changes = arr(item.changes);
return toolCall({
id: str(item?.id),
name: "apply_patch",
kind: "edit",
title: changeTitle(changes),
status: toolStatus(item.status),
content: diffContent(changes),
locations: changes.map((change) => ({ path: str(change?.path) })),
});
}
case "mcpToolCall": {
const name = `${str(item.server)}.${str(item.tool)}`;
const error = item.error ?? null;
const result = item.result ?? null;
const content: ToolContent[] = [];
if (error !== null) content.push({ type: "text", text: renderJson(error) });
else if (result !== null) content.push({ type: "text", text: renderJson(result) });
return toolCall({
id: str(item?.id),
name,
kind: "other",
title: name,
status: error !== null ? "failed" : toolStatus(item.status),
input: item.arguments ?? null,
content,
});
}
// A plugin tool the host ran. The input stays whole: for code mode's
// `execute` it is the code the agent wrote.
case "dynamicToolCall": {
const tool = str(item.tool);
return toolCall({
id: str(item?.id),
name: tool,
kind: "other",
title: tool,
status: item.success === false ? "failed" : toolStatus(item.status),
input: item.arguments ?? null,
content: arr(item.contentItems).flatMap((part) => {
const content = dynamicToolContent(part);
return content ? [content] : [];
}),
});
}
case "webSearch": {
const query = str(item.query);
return toolCall({
id: str(item?.id),
name: "web_search",
kind: "fetch",
title: query || "Web search",
status: "completed",
input: { query },
});
}
case "imageView": {
const path = str(item.path);
return toolCall({
id: str(item?.id),
name: "view_image",
kind: "read",
title: path,
status: "completed",
input: { path },
locations: [{ path }],
});
}
default:
return null;
}
}
function dynamicToolContent(part: NonNullable<Item["contentItems"]>[number]): ToolContent | null {
if (part?.type === "inputText") return { type: "text", text: str(part.text) };
if (part?.type === "inputImage") {
const url = str(part.imageUrl);
if (!url.startsWith("data:")) return null;
const at = url.indexOf(";base64,");
if (at < 0) return null;
return { type: "image", mimeType: url.slice("data:".length, at), data: url.slice(at + ";base64,".length) };
}
return null;
}
/// A plugin tool's result as the answer to Codex's `item/tool/call`.
/// Images travel as data URLs, the only image form Codex takes here.
export function dynamicToolResponse(result: ToolResult | null | undefined) {
const contentItems = arr(result?.content).flatMap<{ type: string; text?: string; imageUrl?: string }>((part) => {
if (part?.type === "text") return [{ type: "inputText", text: str(part.text) }];
if (part?.type === "image")
return [{ type: "inputImage", imageUrl: `data:${str(part.mimeType)};base64,${str(part.data)}` }];
return [];
});
return { contentItems, success: !result?.isError };
}
function renderJson(value: unknown) {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
// --- transcript -------------------------------------------------------------------
export function userBlocks(content: readonly unknown[] | null | undefined): ContentBlock[] {
const blocks: ContentBlock[] = [];
for (const raw of arr(content)) {
const parsed = wire.input.safeParse(raw);
if (!parsed.success) continue;
const input = parsed.data;
switch (input?.type) {
case "text":
blocks.push({ type: "text", text: str(input.text) });
break;
case "localImage":
case "mention":
case "skill":
blocks.push({ type: "file_ref", path: str(input.path) });
break;
default:
break;
}
}
return blocks;
}
/// The text of a reasoning item: the summary when Codex produced one,
/// otherwise the raw chain-of-thought content.
export function reasoningText(
summary: readonly unknown[] | null | undefined,
content: readonly unknown[] | null | undefined,
) {
const joined = arr(summary).join("\n\n");
return joined.trim() ? joined : arr(content).join("\n\n");
}
/// What `codex exec` prints before it reads a prompt from stdin. It is
/// not reasoning; an item that says only this is left out.
export const STDIN_NOTICE = "Reading additional input from stdin...";
export function isStdinNotice(text: unknown) {
return typeof text === "string" && text.trim() === STDIN_NOTICE;
}
export function planFromSteps(
steps: readonly { step?: string | null; status?: string | null }[] | null | undefined,
): Plan {
return {
entries: arr(steps).map((step) => ({
content: str(step?.step),
status: step?.status === "inProgress" ? "in_progress" : step?.status === "completed" ? "completed" : "pending",
})),
};
}
/// One transcript item per Codex item, for `read_session`.
export function transcriptItem(item: Item | null | undefined): TranscriptItem | null {
const id = itemId(item);
if (id === null || !item) return null;
switch (item.type) {
case "userMessage": {
const blocks = userBlocks(item.content);
return blocks.length ? { id, role: "user", blocks } : null;
}
case "agentMessage": {
const text = str(item.text);
return text ? { id, role: "assistant", text } : null;
}
case "reasoning": {
const text = reasoningText(item.summary, item.content);
if (!text.trim() || isStdinNotice(text)) return null;
return { id, role: "reasoning", text };
}
case "plan":
return { id, role: "plan", plan: { entries: [{ content: str(item.text), status: "pending" }] } };
case "contextCompaction":
return { id, role: "notice", text: "Context compacted" };
default: {
const call = toolCallFromItem(item);
return call ? { id, role: "tool", call } : null;
}
}
}
// --- subagents ---------------------------------------------------------------------
/// A task status from an `agentsStates` entry. `pendingInit` and
/// `running` are only a snapshot taken when the item was written, so they
/// never override what the child's own turns say.
export function collabStatus(status: unknown) {
switch (status) {
case "completed":
case "shutdown":
return "completed";
case "errored":
case "notFound":
return "failed";
case "interrupted":
return "cancelled";
default:
return null;
}
}
/// A task status from a multi-agent v2 `subAgentActivity` kind.
export function activityStatus(kind: unknown) {
if (kind === "completed") return "completed";
if (kind === "interrupted") return "cancelled";
return null;
}
/// A task status from the end of one of the child's own turns. A child
/// thread that finished a turn can be given more work, so it is idle.
export function turnTaskStatus(status: unknown) {
switch (status) {
case "completed":
return "idle";
case "interrupted":
return "cancelled";
case "failed":
return "failed";
default:
return "running";
}
}
export function isLive(status: unknown) {
return status === "queued" || status === "running" || status === "waiting";
}
/// The subagent threads an item starts: a v1 `spawnAgent` call names them
/// in `receiverThreadIds`, a v2 spawn is a `started` activity. Other
/// collab tools and `interacted` activities address agents that exist
/// already (inside a child, `interacted` with `/root` is a message to its
/// parent).
export function spawnedChildren(item: Item | null | undefined) {
if (item?.type === "collabAgentToolCall" && item.tool === "spawnAgent") {
return arr(item.receiverThreadIds).filter((id): id is string => typeof id === "string" && Boolean(id));
}
if (item?.type === "subAgentActivity" && item.kind === "started" && str(item.agentThreadId)) {
return [str(item.agentThreadId)];
}
return [];
}
function* items(turns: readonly Turn[] | null | undefined) {
for (const turn of arr(turns)) yield* arr(turn?.items);
}
/// Every subagent thread the turns start, in order, without repeats.
export function childIds(turns: readonly Turn[] | null | undefined) {
const seen = new Set();
const out = [];
for (const item of items(turns)) {
for (const id of spawnedChildren(item)) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
}
return out;
}
/// What the parent's own items say about each child: the v1 spawn call,
/// the latest final status, the latest answer, the v2 path.
function childNotes(turns: readonly Turn[] | null | undefined) {
const notes = new Map<string, ChildNote>();
const entry = (id: string) => {
const note = notes.get(id) ?? { spawn: null, status: null, message: null, path: null };
notes.set(id, note);
return note;
};
for (const item of items(turns)) {
if (item?.type === "collabAgentToolCall") {
if (item.tool === "spawnAgent") {
for (const child of arr(item.receiverThreadIds)) {
const note = entry(str(child));
note.spawn ??= {
item: str(item?.id),
prompt: opt(item.prompt),
model: opt(item.model),
effort: opt(item.reasoningEffort),
};
}
}
for (const [child, state] of Object.entries(item.agentsStates ?? {})) {
const note = entry(str(child));
const status = collabStatus(state?.status);
if (status) note.status = status;
const message = opt(state?.message);
if (message && message.trim()) note.message = message;
}
} else if (item?.type === "subAgentActivity") {
const note = entry(str(item.agentThreadId));
if (item.kind === "started") note.path ??= str(item.agentPath);
const status = activityStatus(item.kind);
if (status) note.status = status;
}
}
return notes;
}
/// The first line of a text, trimmed, cut at 80 characters.
export function firstLine(text: unknown) {
const line = str(text).trim().split("\n")[0]?.replace(/\r$/, "").trim();
const chars = Array.from(line ?? "");
return chars.length > 80 ? `${chars.slice(0, 80).join("")}…` : (line ?? "");
}
/// The title of a subagent: the first line of its instruction, else the
/// task name its parent gave it (multi-agent v2 sends the instruction
/// encrypted, so the name is all there is), else its nickname or role.
export function taskTitle(prompt: string | null, taskName: string | null, name: string | null) {
const fromPrompt = prompt ? firstLine(prompt) : "";
if (fromPrompt) return fromPrompt;
const fromTask = taskName ? firstLine(taskName.replaceAll("_", " ")) : "";
if (fromTask) return fromTask;
return name ?? "Subagent";
}
/// The task name in a v2 agent path: its last segment (`/root/scout` →
/// `scout`).
export function pathTaskName(path: unknown) {
const name = str(path).split("/").pop();
return name && name !== "root" ? name : null;
}
/// Unix seconds as an RFC 3339 time, or `null`.
export function timestamp(seconds: unknown) {
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
const date = new Date(seconds * 1000);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
/// A thread's transcript with every subagent it started rebuilt in place.
///
/// The spawn (or v2 `started` activity) becomes a task item whose items
/// are the child's own transcript, read from `children` (a Map of thread
/// id to `{ thread, turns }`) and nested the same way at any depth. The
/// other collab calls (`wait`, `sendInput`, `closeAgent`, …) are
/// bookkeeping the task row already shows, so they are left out.
/// `parentTask` is the task this thread is, or `null` for the chat.
export function transcriptWithSubagents(
turns: readonly Turn[] | null | undefined,
children: Map<string, History> = new Map(),
parentTask: string | null = null,
): TranscriptItem[] {
const visited = new Set<string>();
if (parentTask) visited.add(parentTask);
return transcriptNested(turns, children, parentTask, visited);
}
function transcriptNested(
turns: readonly Turn[] | null | undefined,
children: Map<string, History>,
parentTask: string | null,
visited: Set<string>,
): TranscriptItem[] {
const notes = childNotes(turns);
const out = [];
for (const item of items(turns)) {
if (item?.type === "collabAgentToolCall" || item?.type === "subAgentActivity") {
for (const child of spawnedChildren(item)) {
// A thread that spawns its own ancestor would loop.
if (visited.has(child)) continue;
visited.add(child);
out.push(
historyTask(child, notes.get(child) ?? null, children.get(child) ?? null, parentTask, children, visited),
);
}
} else {
const mapped = transcriptItem(item);
if (mapped) out.push(mapped);
}
}
return out;
}
function historyTask(
id: string,
note: ChildNote | null,
child: History | null,
parentTask: string | null,
children: Map<string, History>,
visited: Set<string>,
): TranscriptItem {
const spawn = note?.spawn ?? null;
const thread = child?.thread ?? null;
const turns = arr(child?.turns);
const name = thread ? (threadNickname(thread) ?? threadRole(thread)) : null;
const taskName = (note?.path ? pathTaskName(note.path) : null) ?? (thread ? threadTaskName(thread) : null);
let prompt = spawn?.prompt && spawn.prompt.trim() ? spawn.prompt : null;
if (!prompt && thread && str(thread.preview).trim()) prompt = thread.preview ?? null;
const lastTurn = turns.length ? turns[turns.length - 1] : null;
const status = note?.status ?? (lastTurn ? turnTaskStatus(lastTurn.status) : null) ?? "idle";
let summary = note?.message ?? null;
if (summary === null) {
const all = [...items(turns)];
for (let i = all.length - 1; i >= 0; i -= 1) {
const item = all[i];
if (item?.type === "agentMessage" && str(item.text).trim()) {
summary = item.text ?? null;
break;
}
}
}
const toolUses = [...items(turns)].filter((item) => toolCallFromItem(item) !== null).length;
const startedAt = timestamp(turns[0]?.startedAt);
const model = spawn?.model ?? opt(thread?.model);
const effort = spawn?.effort ?? opt(thread?.reasoningEffort);
const task: Task = { id, title: taskTitle(prompt, taskName, name), status };
if (spawn) task.toolCallId = spawn.item;
if (parentTask) task.parentTaskId = parentTask;
if (name) task.name = name;
if (model) task.model = model;
if (effort) task.effort = effort;
if (prompt) task.prompt = prompt;
if (summary) task.summary = summary;
if (startedAt) task.startedAt = startedAt;
const endedAt = !isLive(status) ? timestamp(lastTurn?.completedAt) : null;
if (endedAt) task.endedAt = endedAt;
if (child) task.toolUses = toolUses;
const out: TranscriptItem = {
// The id the host gives a live task, so a reload and a live run name
// the same row.
id: `task-${id}`,
role: "task",
task,
items: transcriptNested(turns, children, id, visited),
};
if (startedAt) out.createdAt = startedAt;
return out;
}
/// A readable title for a thread: its Codex name, else its preview, else
/// the first user message of the loaded turns.
export function threadTitle(name: unknown, preview: unknown, turns: readonly Turn[] | null | undefined) {
if (typeof name === "string" && name.trim()) return name.trim();
if (str(preview).trim()) return firstLine(preview);
for (const item of items(turns)) {
if (item?.type !== "userMessage") continue;
for (const raw of arr(item.content)) {
const input = wire.input.safeParse(raw);
if (!input.success) continue;
if (input.data.type === "text" && str(input.data.text).trim()) return firstLine(input.data.text);
}
}
return null;
}
// --- rate limits ----------------------------------------------------------------------
/// A Codex rate-limit snapshot in the shared usage-limit shape. Codex
/// names its buckets `primary` and `secondary` and gives their length in
/// minutes, so the label comes from that rather than from a guess.
export function usageLimits(
response: z.infer<typeof wire.limits> | null | undefined,
recovery = usageRecovery(response),
): UsageLimits {
const snapshot = response?.rateLimits ?? {};
const blocked = snapshot.rateLimitReachedType === "rate_limit_reached";
const windows = [];
for (const id of ["primary", "secondary"] as const) {
const window = snapshot[id];
if (!window || typeof window !== "object") continue;
const minutes = Number.isInteger(window.windowDurationMins) ? (window.windowDurationMins ?? null) : null;
const usedPercent = typeof window.usedPercent === "number" ? window.usedPercent : 0;
const out: UsageWindow = {
id,
label: windowLabel(id, minutes, opt(snapshot.limitName)),
usedPercent,
blocked: blocked && usedPercent >= 100,
};
const resetsAt = timestamp(window.resetsAt);
if (resetsAt) out.resetsAt = resetsAt;
if (minutes !== null) out.windowMinutes = minutes;
// Only the window that is actually full is blocked.
windows.push(out);
}
const limits: UsageLimits = { windows, recovery };
const count = response?.rateLimitResetCredits?.availableCount;
if (typeof count === "number" && Number.isInteger(count) && count > 0) limits.resetCredits = count;
return limits;
}
/// Only the full read's identity-validated boolean is availability. Sparse
/// pushes and rounded meters cannot turn an unavailable read into permission.
export function usageRecovery(
response: z.infer<typeof wire.limits> | null | undefined,
source = "codex/account/rateLimits/read",
observedAt = new Date().toISOString(),
activeAccountId?: string | null,
): UsageRecovery {
const fullRead = source === "codex/account/rateLimits/read";
const allowed = fullRead ? response?.ordinaryUsageAllowed : null;
const recovery: UsageRecovery = {
availability: allowed === true ? "allowed" : allowed === false ? "blocked" : "unknown",
observedAt,
source,
reason:
allowed === true
? "Native included usage is allowed"
: allowed === false
? "Native included usage is blocked"
: "Native usage availability was not reported",
};
const snapshotAccountId = fullRead ? response?.accountId : null;
if (snapshotAccountId && activeAccountId && snapshotAccountId !== activeAccountId) {
recovery.availability = "unknown";
recovery.reason = "Native usage snapshot does not match the active account";
return recovery;
}
const identity = snapshotAccountId || activeAccountId;
if (identity) recovery.identity = identity;
const snapshots = response?.rateLimitsByLimitId
? Object.values(response.rateLimitsByLimitId)
: [response?.rateLimits];
const reached = snapshots.filter(
(snapshot) => snapshot && (snapshot.rateLimitReachedType || snapshot.spendControlReached),
);
// Permission and usageLimitExceeded describe the account, not a responsible
// bucket. Keep recovery account-wide: narrowing to a currently exhausted
// bucket would change the scope as soon as that bucket recovers.
const resets: (string | null)[] = [];
for (const snapshot of reached) {
if (!snapshot) continue;
if (snapshot.spendControlReached === true) resets.push(timestamp(snapshot.individualLimit?.resetsAt));
if (snapshot.rateLimitReachedType === "rate_limit_reached") {
const exhausted = [snapshot.primary, snapshot.secondary].filter(
(window) => window && (window.usedPercent ?? 0) >= 100,
);
// A reached quota with no responsible window is not a timed remedy.
if (!exhausted.length) resets.push(null);
for (const window of exhausted) resets.push(timestamp(window?.resetsAt));
} else if (snapshot.rateLimitReachedType) {
// Credits/billing ceilings are not ordinary subscription-window resets.
resets.push(null);
}
}
if (resets.length && resets.every((reset): reset is string => reset !== null))
recovery.resetsAt = resets.sort().at(-1);
return recovery;
}
function windowLabel(id: string, minutes: number | null, limitName: string | null) {
if (minutes === null) return limitName ?? (id === "primary" ? "Current limit" : id);
const week = 60 * 24 * 7;
const day = 60 * 24;
if (minutes % week === 0) {
const weeks = minutes / week;
return weeks === 1 ? "Weekly" : `${weeks} weeks`;
}
if (minutes % day === 0) {
const days = minutes / day;
return days === 1 ? "Daily" : `${days} days`;
}
if (minutes % 60 === 0) {
const hours = minutes / 60;
return hours === 1 ? "Hourly" : `${hours} hours`;
}
return `${minutes} minutes`;
}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.