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