Official

opencode

OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.

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/opencode@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

Files

map.ts40.8 KB
// Turns OpenCode server events and history into Convergence agent events
// and transcript items (the Rust `map.rs`).
//
// The mapping is pure: `Mapper.event` takes one decoded `GET /event`
// payload and returns what the agent should do with it. Everything that
// needs I/O (a permission reply, finishing a run) comes back as a mapped
// value the agent carries out:
//
//   { type: "event", session, event }       forward an agent event
//   { type: "idle", session }               the session stopped working
//   { type: "failed", session, message }    the session reported an error
//   { type: "approval", session, request }  a permission blocks a tool
//   { type: "question", session, request }  the question tool asks
//   { type: "resolved", session, approval | question }  answered elsewhere
//   { type: "child", child }                a subagent session appeared
//   { type: "task_link", link }             a `task` call names its subagent
//   { type: "task_result", result }         a background subagent's report
//   { type: "assistant", session, provider, model, variant }
import { MCP_SERVER } from "../sdk/mcp.ts";
import * as z from "zod";
import type {
  AgentEventKind,
  ApprovalOption,
  ApprovalRequest,
  PlanStatus,
  QuestionField,
  QuestionOption,
  QuestionRequest,
  TaskInfo,
  TaskStatus,
  ToolCall,
  ToolContent,
  ToolKind,
  ToolStatus,
  TranscriptItem,
  Usage,
} from "convergence/protocol";

export interface Part {
  id?: unknown;
  type?: unknown;
  tool?: unknown;
  messageID?: unknown;
  sessionID?: unknown;
  callID?: unknown;
  synthetic?: unknown;
  ignored?: unknown;
  text?: unknown;
  state?: unknown;
  tokens?: unknown;
  cost?: unknown;
  // The server sends more per part type; the mapping reads what it needs.
  [key: string]: unknown;
}

export interface TaskOutput {
  id: string;
  state: string;
  summary: string | null;
  text: string;
}

export interface TaskOutcome {
  child: string;
  status: TaskStatus;
  summary: string | null;
}

export interface TaskLink {
  child: string;
  parent: string;
  toolCallId: string;
  title: string | null;
  prompt: string | null;
  agent: string | null;
  model: string | null;
  background: boolean;
  startedAt: string | null;
  endedAt: string | null;
  outcome: TaskOutcome | null;
}

export interface ChildInfo {
  id: string;
  parent: string;
  title: string | null;
  agent: string | null;
}

export type Mapped =
  | { type: "event"; session: string; event: AgentEventKind }
  | { type: "idle"; session: string }
  | { type: "failed"; session: string; message: string }
  | { type: "approval"; session: string; request: ApprovalRequest }
  | { type: "question"; session: string; request: QuestionRequest }
  | { type: "resolved"; session: string; approval?: string; question?: string }
  | { type: "child"; child: ChildInfo }
  | { type: "task_link"; link: TaskLink }
  | { type: "task_result"; result: TaskOutcome }
  | {
      type: "assistant";
      session: string;
      parent: string | null;
      provider: string | null;
      model: string | null;
      variant: string | null;
    };

const recordSchema = z.record(z.string(), z.unknown());

const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
/// A JSON object as a record; anything else reads as empty.
export const obj = (value: unknown): Record<string, unknown> => {
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
  const parsed = recordSchema.safeParse(value);
  return parsed.success ? parsed.data : {};
};
export const arr = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
const nonEmpty = (value: unknown): string | null => (typeof value === "string" && value !== "" ? value : null);

/// Epoch milliseconds as an ISO instant, or `null`.
export function millis(value: unknown): string | null {
  if (typeof value !== "number" || !Number.isFinite(value)) return null;
  const date = new Date(value);
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
}

/// Drops `null` and `undefined` fields (and `false` for the flags the host
/// leaves out when unset).
function compact<T extends object>(object: T): { [K in keyof T]: Exclude<T[K], null | undefined> } {
  const out: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(object)) if (value !== null && value !== undefined) out[key] = value;
  return out as { [K in keyof T]: Exclude<T[K], null | undefined> };
}

// --- tools --------------------------------------------------------------------------

/// The prefix OpenCode gives the tools of the host's MCP server:
/// `<server>_<tool>`.
export const PLUGIN_TOOL_PREFIX = `${MCP_SERVER}_`;

/// A plugin tool's own name, when `tool` is one served by the host.
export function pluginToolName(tool: unknown): string | null {
  return typeof tool === "string" && tool.startsWith(PLUGIN_TOOL_PREFIX) && tool.length > PLUGIN_TOOL_PREFIX.length
    ? tool.slice(PLUGIN_TOOL_PREFIX.length)
    : null;
}

/// Classification from the native tool name, the only structured signal
/// OpenCode gives. Command strings are never parsed.
export function toolKind(tool: unknown): ToolKind {
  switch (tool) {
    case "read":
      return "read";
    case "edit":
    case "write":
    case "patch":
    case "multiedit":
    case "apply_patch":
      return "edit";
    case "bash":
    case "shell":
      return "execute";
    case "grep":
    case "glob":
    case "list":
      return "search";
    case "webfetch":
      return "fetch";
    case "task":
      return "task";
    case "todowrite":
    case "todoread":
      return "think";
    default:
      return "other";
  }
}

export function toolStatus(status: unknown): ToolStatus {
  switch (status) {
    case "running":
      return "running";
    case "completed":
      return "completed";
    case "error":
      return "failed";
    default:
      return "pending";
  }
}

function pathOf(input: Record<string, unknown>): string | null {
  return str(input["filePath"]) ?? str(input["path"]);
}

function toolTitle(part: Part): string {
  const state = obj(part.state);
  const input = obj(state["input"]);
  const own = nonEmpty(state["title"]);
  if (own) return own;
  const command = str(input["command"]);
  if (command !== null) return command;
  const path = pathOf(input);
  if (path !== null) return path.split("/").pop() || path;
  return pluginToolName(part.tool) ?? String(part.tool ?? "");
}

function textContent(text: unknown): { type: "text"; text: string }[] {
  return typeof text === "string" && text !== "" ? [{ type: "text", text }] : [];
}

function toolContent(part: Part): ToolContent[] {
  const state = obj(part.state);
  const input = obj(state["input"]);
  const metadata = obj(state["metadata"]);
  const error = nonEmpty(state["error"]);
  if (error) return [{ type: "text", text: error }];
  switch (toolKind(part.tool)) {
    case "execute": {
      const terminal: { type: "terminal"; command: string; output: string; cwd?: string; exitCode?: number } = {
        type: "terminal",
        command: str(input["command"]) ?? String(part.tool ?? ""),
        output: str(metadata["output"]) ?? str(state["output"]) ?? "",
      };
      const cwd = str(input["cwd"]);
      if (cwd !== null) terminal.cwd = cwd;
      const exit = metadata["exit"];
      if (typeof exit === "number" && Number.isInteger(exit)) terminal.exitCode = exit;
      return [terminal];
    }
    case "edit": {
      const path = pathOf(input) ?? "";
      // `metadata.diff` is a unified patch of the applied change; `write`
      // reports the whole new file instead.
      const diff = str(metadata["diff"]);
      if (diff !== null) return [{ type: "diff", path, diff }];
      const content = str(input["content"]);
      if (content !== null) return [{ type: "diff", path, newText: content }];
      return textContent(str(state["output"]));
    }
    case "read":
      return textContent(str(metadata["preview"]) ?? str(state["output"]));
    default:
      return textContent(str(state["output"]));
  }
}

/// The whole tool call as the UI should see it now.
export function toolCall(part: Part): ToolCall {
  const state = obj(part.state);
  const input = state["input"] ?? {};
  const path = pathOf(obj(input));
  return {
    id: String(part.id),
    name: String(part.tool ?? ""),
    kind: toolKind(part.tool),
    title: toolTitle(part),
    status: toolStatus(state["status"]),
    input,
    content: toolContent(part),
    locations: path !== null ? [{ path }] : [],
  };
}

// --- approvals and questions ---------------------------------------------------------

interface PermissionRequestWire {
  id?: unknown;
  sessionID?: unknown;
  permission?: unknown;
  patterns?: unknown;
  metadata?: unknown;
  always?: unknown;
  tool?: unknown;
}

/// The command a `bash` request asks to run: named in the metadata, or the
/// one pattern the request matches against.
function requestCommand(request: PermissionRequestWire): string | null {
  const direct = str(obj(request.metadata)["command"]);
  if (direct !== null) return direct;
  const pattern = arr(request.patterns).find((entry) => entry !== "*");
  return typeof pattern === "string" ? pattern : null;
}

/// What the agent is asking to do, in words.
export function requestSummary(request: PermissionRequestWire): string {
  const command = str(obj(request.metadata)["command"]);
  if (command !== null) return command;
  const permission = String(request.permission ?? "");
  const action = pluginToolName(permission) ?? permission.replace(/_/g, " ");
  // `*` is the catch-all rule matching, which says nothing to a reader.
  const patterns = arr(request.patterns).filter((pattern) => typeof pattern === "string" && pattern !== "*");
  return patterns.length ? `${action} ${patterns.join(", ")}` : action;
}

/// The action a permission request describes when the tool call behind it
/// was not announced (recovered after a reconnect, or raised before the
/// part arrived).
function permissionCall(request: PermissionRequestWire): ToolCall {
  const kind = toolKind(request.permission);
  // A command request shows the terminal line it will run, not metadata.
  const command = kind === "execute" ? requestCommand(request) : null;
  const metadata = obj(request.metadata);
  const content: ToolContent[] = [];
  if (command !== null) {
    const terminal: { type: "terminal"; command: string; output: string; cwd?: string } = {
      type: "terminal",
      command,
      output: "",
    };
    const cwd = str(metadata["workdir"]) ?? str(metadata["cwd"]);
    if (cwd !== null) terminal.cwd = cwd;
    content.push(terminal);
  }
  return {
    id: str(obj(request.tool)["callID"]) ?? String(request.id),
    name: String(request.permission ?? ""),
    kind,
    title: command ?? requestSummary(request),
    status: "pending",
    input: request.metadata ?? {},
    content,
    locations: [],
  };
}

/// The approval card for a permission request. `known` is the tool call
/// the server blocks, when the stream announced it already; without it the
/// request still describes itself, because a card that names no action
/// cannot be judged.
export function approval(
  request: PermissionRequestWire,
  known: ToolCall | null = null,
): ApprovalRequest & { toolCall: ToolCall } {
  const permission = String(request.permission ?? "");
  const command = str(obj(request.metadata)["command"]);
  const pattern = arr(request.patterns).find((entry) => typeof entry === "string" && entry !== "*");
  const title =
    command !== null
      ? `${permission} ${command}`
      : typeof pattern === "string"
        ? `${permission} ${pattern}`
        : permission;
  const always = arr(request.always);
  const options: ApprovalOption[] = [{ id: "once", name: "Allow once", kind: "allow_once" }];
  if (always.length) options.push({ id: "always", name: "Always allow", kind: "allow_always" });
  options.push({ id: "reject", name: "Reject", kind: "reject_once" });
  // A part announced while its arguments were still streaming has an empty
  // input and says nothing about the command; the request does.
  let toolCallValue: ToolCall;
  if (known && Object.keys(obj(known.input)).length) toolCallValue = known;
  else if (known) toolCallValue = { ...permissionCall(request), id: known.id };
  else toolCallValue = permissionCall(request);
  const out: ApprovalRequest & { toolCall: ToolCall } = {
    id: String(request.id),
    title,
    toolCall: toolCallValue,
    options,
  };
  // OpenCode remembers an `always` grant against the directory, not the
  // chat, so the user decides for every session on this workspace.
  if (always.length)
    out.warning = "Allowing always applies to matching requests in every OpenCode session on this workspace.";
  return out;
}

/// The id of one question inside a request: the position keeps it unique,
/// the header makes a mismatch visible instead of silent when the UI
/// returns the answers in another order.
export function questionId(index: number, header: unknown): string {
  let slug = "";
  for (const character of String(header ?? "")
    .trim()
    .toLowerCase()) {
    if (/^[a-z0-9_-]$/.test(character)) slug += character;
    else if (!slug.endsWith("-")) slug += "-";
  }
  slug = slug.replace(/^-+|-+$/g, "");
  return slug ? `question-${index}-${slug}` : `question-${index}`;
}

interface QuestionWire {
  id?: unknown;
  questions?: unknown;
  sessionID?: unknown;
}

export function question(request: QuestionWire): QuestionRequest {
  const questions = arr(request.questions);
  const fields = questions.map((info, index) => {
    const record = obj(info);
    const header = str(record["header"]) ?? "";
    const field: QuestionField = {
      id: questionId(index, header),
      label: String(record["question"] ?? ""),
      kind: record["multiple"] ? "multi_select" : "select",
      options: arr(record["options"]).map((option) => {
        const entry = obj(option);
        const choice: QuestionOption = { value: String(entry["label"] ?? ""), label: String(entry["label"] ?? "") };
        const description = nonEmpty(entry["description"]);
        if (description) choice.description = description;
        return choice;
      }),
      allowOther: !!record["custom"],
      required: true,
    };
    if (header) field.description = header;
    return field;
  });
  const out: QuestionRequest = { id: String(request.id), fields };
  if (questions.length) out.message = String(obj(questions[0])["question"] ?? "");
  return out;
}

function planStatus(status: unknown): PlanStatus {
  if (status === "in_progress") return "in_progress";
  if (status === "completed") return "completed";
  return "pending";
}

// --- titles and errors ----------------------------------------------------------------

const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;

/// Whether a session title is a placeholder: OpenCode mints `New session -
/// <instant>` for a session created without a title and re-sends it on
/// every update, which would overwrite the name the user or the agent gave
/// the chat. `New Chat` is the placeholder this plugin used to send.
export function isPlaceholderTitle(title: unknown): boolean {
  const text = String(title ?? "").trim();
  if (!text || text === "New Chat") return true;
  for (const prefix of ["New session - ", "Child session - "]) {
    if (text.startsWith(prefix)) return ISO_INSTANT.test(text.slice(prefix.length));
  }
  return false;
}

/// A readable message from any of the server's error shapes.
export function errorMessage(error: unknown): string {
  if (error && typeof error === "object") {
    const record = obj(error);
    const message = record["data"] !== undefined ? obj(record["data"])["message"] : undefined;
    if (typeof message === "string") return message;
    if (typeof record["name"] === "string") return record["name"];
  }
  if (typeof error === "string") return error;
  if (error === null || error === undefined) return "opencode reported an error";
  return JSON.stringify(error);
}

// --- usage ---------------------------------------------------------------------------------

const count = (value: unknown): number =>
  typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;

/// What one finished step used. `usedTokens` is what the context holds
/// after the step; the breakdown is what the request cost and need not add
/// up to it. The context window is filled in by the agent.
export function stepUsage(step: { tokens?: unknown; cost?: unknown }): Usage | null {
  const tokens = step?.tokens;
  if (!tokens || typeof tokens !== "object") return null;
  const record = obj(tokens);
  const cache = obj(record["cache"]);
  const total =
    typeof record["total"] === "number"
      ? record["total"]
      : count(record["input"]) +
        count(record["output"]) +
        count(record["reasoning"]) +
        count(cache["read"]) +
        count(cache["write"]);
  const usage: Usage = {
    usedTokens: count(total),
    inputTokens: count(record["input"]),
    cachedInputTokens: count(cache["read"]),
    outputTokens: count(record["output"]),
    reasoningTokens: count(record["reasoning"]),
  };
  if (typeof step.cost === "number") usage.costUsd = step.cost;
  return usage;
}

/// Folds one step into a subagent's usage: `usedTokens` is the latest
/// step's context (what Claude reports for its subagents too; summing
/// would count the same context once per step), the cost is the sum.
export function addUsage(total: Usage | null, step: Usage): Usage {
  const before = total?.costUsd;
  const current = step.costUsd;
  const out = { ...step };
  if (before === undefined && current === undefined) delete out.costUsd;
  else out.costUsd = (before ?? 0) + (current ?? 0);
  return out;
}

// --- subagents ---------------------------------------------------------------------------

/// A subagent's session title without the ` (@explore subagent)` suffix
/// the server appends to the task description.
export function taskTitle(title: unknown): string {
  const trimmed = String(title ?? "").trim();
  const start = trimmed.lastIndexOf(" (@");
  if (start >= 0 && trimmed.endsWith(" subagent)")) return trimmed.slice(0, start).trim();
  return trimmed;
}

/// The first line of a prompt, as a title when nothing shorter was given.
export function firstLine(text: unknown): string | null {
  for (const line of String(text ?? "").split("\n")) {
    const trimmed = line.trim();
    if (trimmed) return trimmed;
  }
  return null;
}

/// How a failed `task` call ended: the server words a stopped subagent as
/// `Task cancelled`, and an aborted parent as an aborted tool.
function errorStatus(error: unknown): TaskStatus {
  return error === "Task cancelled" || String(error).toLowerCase().includes("aborted") ? "cancelled" : "failed";
}

/// The `<task id=".." state="..">` report the `task` tool answers with,
/// and that a background subagent's result is injected as:
/// `{ id, state, summary, text }`, or `null` for any other text.
export function parseTaskOutput(text: unknown): TaskOutput | null {
  const trimmed = String(text ?? "").trim();
  if (!trimmed.startsWith("<task ")) return null;
  const rest = trimmed.slice("<task ".length);
  const close = rest.indexOf(">");
  if (close < 0) return null;
  const head = rest.slice(0, close);
  let body = rest.slice(close + 1);
  const attribute = (name: string): string | null => {
    const marker = `${name}="`;
    const at = head.indexOf(marker);
    if (at < 0) return null;
    const start = at + marker.length;
    const end = head.indexOf('"', start);
    return end < 0 ? null : head.slice(start, end);
  };
  const id = attribute("id");
  const state = attribute("state");
  if (id === null || state === null) return null;
  body = body.trim();
  if (body.endsWith("</task>")) body = body.slice(0, -"</task>".length);
  body = body.trim();
  const between = (open: string, closeTag: string): string | null => {
    const at = body.indexOf(open);
    if (at < 0) return null;
    const start = at + open.length;
    const end = body.lastIndexOf(closeTag);
    if (end < start) return null;
    return body.slice(start, end).replace(/^\n+|\n+$/g, "");
  };
  const summary = between("<summary>", "</summary>");
  const result = between("<task_result>", "</task_result>") ?? between("<task_error>", "</task_error>") ?? "";
  return { id, state, summary, text: result };
}

/// How the subagent a report names ended, when the report says it did:
/// `{ child, status, summary }`.
export function taskResult(output: TaskOutput | null | undefined): TaskOutcome | null {
  if (!output) return null;
  let status: TaskStatus;
  if (output.state === "completed") status = "completed";
  else if (output.state === "error") status = errorStatus(output.text.trim());
  else return null;
  const text = output.text.trim();
  return { child: output.id, status, summary: text || null };
}

/// What a `task` tool part says about its subagent, once the server has
/// named the subagent's session in `state.metadata.sessionId`.
export function taskLink(session: string, part: Part): TaskLink | null {
  if (part?.tool !== "task") return null;
  const state = obj(part.state);
  const input = obj(state["input"]);
  const metadata = obj(state["metadata"]);
  const child = str(metadata["sessionId"]);
  if (child === null) return null;
  const rawTitle = str(input["description"]) ?? str(state["title"]);
  const title = rawTitle !== null ? taskTitle(rawTitle) || null : null;
  let outcome: TaskOutcome | null = null;
  if (state["status"] === "completed") {
    const parsed = parseTaskOutput(state["output"]);
    // A background task completes at once with a `running` report; its
    // result arrives later as a synthetic message.
    if (parsed) outcome = taskResult(parsed);
    else {
      const output = state["output"];
      outcome = {
        child,
        status: "completed",
        summary: typeof output === "string" && output.trim() ? output : null,
      };
    }
  } else if (state["status"] === "error") {
    const error = str(state["error"]) ?? "";
    outcome = { child, status: errorStatus(error), summary: error || null };
  }
  const time = obj(state["time"]);
  return {
    child,
    parent: session,
    toolCallId: String(part.id),
    title,
    prompt: nonEmpty(input["prompt"]),
    agent: str(input["subagent_type"]),
    model: str(obj(metadata["model"])["modelID"]),
    background: metadata["background"] === true,
    startedAt: millis(time["start"]),
    endedAt: outcome ? millis(time["end"]) : null,
    outcome,
  };
}

// --- the event mapper ------------------------------------------------------------------------

export interface Envelope {
  type?: unknown;
  properties?: unknown;
  data?: unknown;
}

/// Per-stream state: which messages are the user's, which tool calls were
/// announced, which steps were counted.
export class Mapper {
  userMessages = new Set<string>();
  startedTools = new Set<string>();
  // Tool calls still waiting to run, by the model's call id. A
  // permission request names that id; the entry goes once the call ends.
  pendingTools = new Map<string, ToolCall>();
  // Steps already reported: a subagent's usage is summed, so a step seen
  // twice would be counted twice.
  finishedSteps = new Set<string>();

  /// Maps one decoded `GET /event` payload. The wire names the payload
  /// `properties`; the generated schema calls it `data`.
  event(envelope: unknown): Mapped[] {
    const record = obj(envelope);
    const type = str(record["type"]);
    const p = obj(record["properties"] ?? record["data"]);
    switch (type) {
      case "message.updated": {
        const info = obj(p["info"]);
        if (typeof info["id"] !== "string" || typeof info["role"] !== "string") return [];
        if (info["role"] === "user") {
          this.userMessages.add(info["id"]);
          return [];
        }
        const session = str(info["sessionID"]);
        if (session !== null && info["role"] === "assistant") {
          return [
            {
              type: "assistant",
              session,
              // Native selection of the user message, not a prompt echo.
              // An aborted/error snapshot alone is not pickup evidence.
              parent: info["error"] == null ? str(info["parentID"]) : null,
              provider: str(info["providerID"]),
              model: str(info["modelID"]),
              variant: str(info["variant"]),
            },
          ];
        }
        return [];
      }
      case "message.part.updated": {
        const session = str(p["sessionID"]);
        const raw = p["part"];
        return session !== null && raw && typeof raw === "object" ? this.part(session, obj(raw)) : [];
      }
      case "message.part.delta":
        return this.delta(p);
      case "session.idle":
        return typeof p["sessionID"] === "string" ? [{ type: "idle", session: p["sessionID"] }] : [];
      case "session.error":
        return typeof p["sessionID"] === "string"
          ? [{ type: "failed", session: p["sessionID"], message: errorMessage(p["error"]) }]
          : [];
      case "session.created":
      case "session.updated": {
        const session = str(p["sessionID"]);
        const info = obj(p["info"]);
        if (session === null || typeof info["id"] !== "string") return [];
        const out: Mapped[] = [];
        const title = str(info["title"]);
        const real = title !== null && !isPlaceholderTitle(title) ? title : null;
        // A session with a parent is a subagent, announced before anything
        // it does, so its events are attributed from the first one.
        if (typeof info["parentID"] === "string") {
          out.push({
            type: "child",
            child: { id: info["id"], parent: info["parentID"], title: real, agent: str(info["agent"]) },
          });
        }
        // The placeholder is re-sent on every update and would overwrite
        // the chat's real title.
        if (real !== null) out.push({ type: "event", session, event: { event: "session_info", title: real } });
        return out;
      }
      // The agent summarized the conversation; everything before this
      // point left the model's window.
      case "session.compacted":
        return typeof p["sessionID"] === "string"
          ? [{ type: "event", session: p["sessionID"], event: { event: "compacted" } }]
          : [];
      case "todo.updated": {
        if (typeof p["sessionID"] !== "string") return [];
        const entries = arr(p["todos"]).flatMap((todo) => {
          const record = obj(todo);
          if (typeof record["content"] !== "string") return [];
          return [{ content: record["content"], status: planStatus(record["status"]) }];
        });
        return [{ type: "event", session: p["sessionID"], event: { event: "plan", entries } }];
      }
      case "permission.asked": {
        if (typeof p["id"] !== "string" || typeof p["sessionID"] !== "string" || typeof p["permission"] !== "string")
          return [];
        const callId = str(obj(p["tool"])["callID"]);
        const known = callId !== null ? (this.pendingTools.get(callId) ?? null) : null;
        return [{ type: "approval", session: p["sessionID"], request: approval(p, known) }];
      }
      case "question.asked":
        if (typeof p["id"] !== "string" || typeof p["sessionID"] !== "string") return [];
        return [{ type: "question", session: p["sessionID"], request: question(p) }];
      // Another client of this server answered; our card must go.
      case "permission.replied":
        if (typeof p["sessionID"] !== "string" || typeof p["requestID"] !== "string") return [];
        return [{ type: "resolved", session: p["sessionID"], approval: p["requestID"] }];
      case "question.replied":
      case "question.rejected":
        if (typeof p["sessionID"] !== "string" || typeof p["requestID"] !== "string") return [];
        return [{ type: "resolved", session: p["sessionID"], question: p["requestID"] }];
      default:
        return [];
    }
  }

  delta(p: Record<string, unknown>): Mapped[] {
    const { sessionID, messageID, partID, field, delta } = p;
    if (
      typeof sessionID !== "string" ||
      typeof messageID !== "string" ||
      typeof partID !== "string" ||
      typeof field !== "string" ||
      typeof delta !== "string"
    )
      return [];
    if (this.userMessages.has(messageID)) return [];
    // Deltas are forwarded byte for byte, keyed by the part.
    if (field === "text")
      return [
        {
          type: "event",
          session: sessionID,
          event: { event: "text_delta", itemId: partID, text: delta, mode: "append" },
        },
      ];
    if (field === "reasoning") {
      return [
        {
          type: "event",
          session: sessionID,
          event: { event: "reasoning_delta", itemId: partID, text: delta, mode: "append" },
        },
      ];
    }
    return [];
  }

  skipText(part: Part): boolean {
    return (
      part.synthetic === true ||
      part.ignored === true ||
      (typeof part.messageID === "string" && this.userMessages.has(part.messageID))
    );
  }

  part(session: string, part: Part): Mapped[] {
    switch (part.type) {
      // Snapshots carry the whole text of the item so far, which is why
      // they replace rather than append.
      case "text": {
        if (typeof part.id !== "string" || typeof part.messageID !== "string") return [];
        // A background subagent reports back through a synthetic user
        // message in the session that started it.
        if (part.synthetic === true) {
          const result = taskResult(parseTaskOutput(part.text));
          if (result) return [{ type: "task_result", result }];
        }
        if (this.skipText(part)) return [];
        return [
          {
            type: "event",
            session,
            event: { event: "text_delta", itemId: part.id, text: String(part.text ?? ""), mode: "replace" },
          },
        ];
      }
      case "reasoning": {
        if (typeof part.id !== "string" || typeof part.messageID !== "string" || this.skipText(part)) return [];
        return [
          {
            type: "event",
            session,
            event: {
              event: "reasoning_delta",
              itemId: part.id,
              text: String(part.text ?? ""),
              mode: "replace",
            },
          },
        ];
      }
      case "tool": {
        if (
          typeof part.id !== "string" ||
          typeof part.tool !== "string" ||
          !part.state ||
          typeof part.state !== "object"
        )
          return [];
        const call = toolCall(part);
        // A permission request names the call id, so the call must be
        // reachable by it while the server waits for an answer.
        const callId = str(part.callID);
        if (callId !== null) {
          if (call.status === "pending" || call.status === "running") this.pendingTools.set(callId, call);
          else this.pendingTools.delete(callId);
        }
        let event: AgentEventKind;
        if (!this.startedTools.has(part.id)) {
          this.startedTools.add(part.id);
          event = { event: "tool_call_started", ...call };
        } else {
          const { id, status, title, kind, input, content, locations } = call;
          event = { event: "tool_call_updated", id, status, title, kind, input, content, locations };
        }
        const out: Mapped[] = [{ type: "event", session, event }];
        const link = taskLink(session, part);
        if (link) out.push({ type: "task_link", link });
        return out;
      }
      case "step-finish": {
        const usage = stepUsage(part);
        if (!usage) return [];
        if (typeof part.id === "string" && part.id) {
          if (this.finishedSteps.has(part.id)) return [];
          this.finishedSteps.add(part.id);
        }
        return [{ type: "event", session, event: { event: "usage", ...usage } }];
      }
      default:
        return [];
    }
  }
}

// --- history ------------------------------------------------------------------------------------

export interface SessionHistory {
  messages: unknown;
  children: unknown;
}

/// The items of one session's messages. `afterTool(part, items)` runs after
/// each tool item, so a subagent can be placed right below its call.
function sessionItems(messages: unknown, afterTool: (part: Part, items: TranscriptItem[]) => void): TranscriptItem[] {
  const items: TranscriptItem[] = [];
  for (const raw of arr(messages)) {
    const message = obj(raw);
    const info = obj(message["info"]);
    const created = millis(obj(info["time"])["created"]) ?? undefined;
    const parts = arr(message["parts"]);
    if (info["role"] === "user") {
      const blocks = parts.flatMap((entry): { type: "text"; text: string }[] => {
        const part = obj(entry);
        if (part["type"] !== "text" || part["synthetic"] === true || part["ignored"] === true) return [];
        if (typeof part["text"] !== "string" || !part["text"]) return [];
        return [{ type: "text", text: part["text"] }];
      });
      if (blocks.length)
        items.push(compact<TranscriptItem>({ id: String(info["id"]), createdAt: created, role: "user", blocks }));
      continue;
    }
    for (const rawPart of parts) {
      const part = obj(rawPart);
      if (
        part["type"] === "text" &&
        typeof part["text"] === "string" &&
        part["text"] &&
        typeof part["id"] === "string"
      ) {
        items.push(
          compact<TranscriptItem>({ id: part["id"], createdAt: created, role: "assistant", text: part["text"] }),
        );
      } else if (
        part["type"] === "reasoning" &&
        typeof part["text"] === "string" &&
        part["text"] &&
        typeof part["id"] === "string"
      ) {
        items.push(
          compact<TranscriptItem>({ id: part["id"], createdAt: created, role: "reasoning", text: part["text"] }),
        );
      } else if (part["type"] === "tool" && typeof part["id"] === "string" && typeof part["tool"] === "string") {
        items.push(compact<TranscriptItem>({ id: part["id"], createdAt: created, role: "tool", call: toolCall(part) }));
        afterTool(part, items);
      }
    }
  }
  return items;
}

/// Longest subagent chain read back from history.
const MAX_TASK_DEPTH = 32;

/// The subagent sessions a session's messages link to, in order.
export function linkedChildren(messages: unknown): string[] {
  const children: string[] = [];
  for (const raw of arr(messages)) {
    for (const rawPart of arr(obj(raw)["parts"])) {
      const part = obj(rawPart);
      if (part["type"] !== "tool" || part["tool"] !== "task") continue;
      const child = str(obj(obj(part["state"])["metadata"])["sessionId"]);
      if (child !== null && !children.includes(child)) children.push(child);
    }
  }
  return children;
}

/// Whether a task status still has work in hand.
export function isLive(status: unknown): status is TaskStatus {
  return status === "queued" || status === "running" || status === "waiting";
}

/// A subagent as history records it.
function historyTask(
  id: string,
  link: TaskLink | null,
  parentTask: string | null,
  info: unknown,
  history: SessionHistory | undefined,
  background: TaskOutcome | null | undefined,
): TaskInfo {
  const messages = arr(history?.messages);
  const assistant = messages.filter((message) => obj(obj(message)["info"])["role"] === "assistant");
  const first = assistant.length ? obj(assistant[0]) : null;
  const last = assistant.length ? obj(assistant[assistant.length - 1]) : null;
  let prompt = link?.prompt ?? null;
  if (prompt === null) {
    const user = messages.find((message) => obj(obj(message)["info"])["role"] === "user");
    const text = arr(user !== undefined ? obj(user)["parts"] : undefined).find((entry) => {
      const part = obj(entry);
      return part["type"] === "text" && part["synthetic"] !== true && typeof part["text"] === "string" && part["text"];
    });
    const found = text !== undefined ? obj(text)["text"] : undefined;
    prompt = typeof found === "string" ? found : null;
  }
  const record = obj(info);
  const infoTitle = str(record["title"]);
  const title =
    link?.title ??
    (infoTitle !== null && !isPlaceholderTitle(infoTitle) ? taskTitle(infoTitle) : null) ??
    (prompt !== null ? firstLine(prompt) : null) ??
    "Subagent";
  let lastText: string | null = null;
  for (let m = assistant.length - 1; m >= 0 && lastText === null; m -= 1) {
    const parts = arr(obj(assistant[m])["parts"]);
    for (let i = parts.length - 1; i >= 0; i -= 1) {
      const entry = obj(parts[i]);
      if (entry["type"] === "text" && typeof entry["text"] === "string" && entry["text"].trim()) {
        lastText = entry["text"];
        break;
      }
    }
  }
  const outcome = link?.outcome ?? background ?? null;
  let status: TaskStatus;
  if (outcome) status = outcome.status;
  // The call that started it is still waiting for it.
  else if (link && !link.background) status = "running";
  else if (last) {
    const lastInfo = obj(last["info"]);
    const error = lastInfo["error"];
    if (error && typeof error === "object")
      status = obj(error)["name"] === "MessageAbortedError" ? "cancelled" : "failed";
    else if (error) status = "failed";
    else status = typeof obj(lastInfo["time"])["completed"] === "number" ? "completed" : "running";
  } else status = "running";
  let usage: Usage | null = null;
  let toolUses = 0;
  for (const raw of messages) {
    for (const rawPart of arr(obj(raw)["parts"])) {
      const entry = obj(rawPart);
      if (entry["type"] === "tool") toolUses += 1;
      else if (entry["type"] === "step-finish") {
        const step = stepUsage(entry);
        if (step) usage = addUsage(usage, step);
      }
    }
  }
  const settled = !isLive(status);
  const firstInfo = obj(first?.["info"]);
  const startedAt =
    link?.startedAt ?? (messages.length ? millis(obj(obj(obj(messages[0])["info"])["time"])["created"]) : null);
  const endedAt = settled
    ? (link?.endedAt ?? (last ? millis(obj(obj(last["info"])["time"])["completed"]) : null))
    : null;
  const task: TaskInfo = { id, title, status };
  if (link?.toolCallId) task.toolCallId = link.toolCallId;
  if (parentTask) task.parentTaskId = parentTask;
  const name = link?.agent ?? str(record["agent"]);
  if (name) task.name = name;
  const model = link?.model ?? str(firstInfo["modelID"]);
  if (model) task.model = model;
  const effort = str(firstInfo["variant"]);
  if (effort) task.effort = effort;
  if (prompt) task.prompt = prompt;
  if (link?.background) task.background = true;
  const summary = outcome?.summary ?? lastText;
  if (summary) task.summary = summary;
  if (startedAt) task.startedAt = startedAt;
  if (endedAt) task.endedAt = endedAt;
  if (usage) task.usage = usage;
  task.toolUses = toolUses;
  return task;
}

function taskItem(task: TaskInfo, items: TranscriptItem[]): TranscriptItem {
  return compact<TranscriptItem>({
    id: `task-${task.id}`,
    createdAt: task.startedAt,
    role: "task",
    task,
    items,
  });
}

function sessionTree(
  session: string,
  parentTask: string | null,
  sessions: Map<string, SessionHistory>,
  expanded: Set<string>,
  depth: number,
): TranscriptItem[] {
  const history = sessions.get(session);
  if (!history) return [];
  // Background results come back as synthetic messages in this session.
  const results = new Map<string, TaskOutcome>();
  for (const raw of arr(history.messages)) {
    for (const rawPart of arr(obj(raw)["parts"])) {
      const part = obj(rawPart);
      if (part["type"] !== "text" || part["synthetic"] !== true) continue;
      const result = taskResult(parseTaskOutput(part["text"]));
      if (result) results.set(result.child, result);
    }
  }
  const children = arr(history.children);
  const childInfo = new Map(
    children.flatMap((child) => {
      const id = obj(child)["id"];
      return typeof id === "string" ? [[id, child] as const] : [];
    }),
  );
  const items = sessionItems(history.messages, (part, out) => {
    const link = taskLink(session, part);
    // A resumed task names a session already shown; it is shown once.
    if (!link || depth >= MAX_TASK_DEPTH || expanded.has(link.child)) return;
    expanded.add(link.child);
    const task = historyTask(
      link.child,
      link,
      parentTask,
      childInfo.get(link.child) ?? null,
      sessions.get(link.child),
      results.get(link.child),
    );
    out.push(taskItem(task, sessionTree(link.child, link.child, sessions, expanded, depth + 1)));
  });
  if (depth < MAX_TASK_DEPTH) {
    // Subagents the server lists but no call names are added at the end.
    for (const raw of children) {
      const child = obj(raw);
      if (typeof child["id"] !== "string" || expanded.has(child["id"])) continue;
      const id = child["id"];
      expanded.add(id);
      const task = historyTask(id, null, parentTask, child, sessions.get(id), results.get(id));
      items.push(taskItem(task, sessionTree(id, id, sessions, expanded, depth + 1)));
    }
  }
  return items;
}

/// The transcript of `root` with every subagent it started, at any depth,
/// as a `task` item right after the `task` call that started it.
/// `sessions` maps a session id to `{ messages, children }`.
export function transcriptTree(root: string, sessions: Map<string, SessionHistory>): TranscriptItem[] {
  return sessionTree(root, null, sessions, new Set([root]), 0);
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.