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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.