Official
acp
ACP agents from the ACP registry (Gemini, Cursor, Droid, Kilo, pi, ...), Oh My Pi, and your own entries (custom.json in the plugin's data folder). Agents are discovered at runtime.
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/acp@0.2.0
Permissions in 0.2.0
Take care. This plugin asks for permissions that can do anything your account can. The app asks you to hold Enable for two seconds or to type the plugin's name before it turns on.
Files
throttle.ts5.1 KB
// Bounding and coalescing of `tool_call_update` traffic.
//
// Some agents resend the whole output of a running tool on every update, a
// redrawing terminal being the worst case. Forwarding each one floods the
// host and makes the window re-render for no visible gain. This keeps the
// volume down without losing anything:
//
// - `bound` caps the text a tool call carries, keeping the newest part.
// - `ToolStream` merges updates and releases them on the rules t3code's
// `decideToolCallUpdateEmission` uses, so a patch is never dropped, only
// delayed and combined.
import type { ToolCallUpdate, ToolContent, ToolStatus } from "convergence/protocol";
/// Longest text a single tool call keeps. Older output is dropped first
/// because the end of a command's output is what the user reads.
export const MAX_CHARS = 8_000;
export const TRUNCATED = "[Earlier output truncated]\n\n";
/// Growth that is worth an immediate update.
const MIN_GROWTH = 256;
/// Updates that may be merged before one is released anyway.
const COALESCE_LIMIT = 10;
/// Caps the text of a tool call's content, newest part kept. Every
/// text-bearing item is capped on its own, so one huge command output
/// cannot hide a later diff or a later message.
export function bound(content: ToolContent[] | null | undefined): void {
if (!Array.isArray(content)) return;
for (const item of content) {
if (item?.type === "text") item.text = cap(item.text);
else if (item?.type === "terminal") item.output = cap(item.output);
// A diff is only useful whole, and an image is not text.
}
}
function cap(text: string): string;
function cap(text: string | undefined): string | undefined;
function cap(text: string | undefined): string | undefined {
if (typeof text !== "string" || text.length <= MAX_CHARS) return text;
const chars = Array.from(text);
if (chars.length <= MAX_CHARS) return text;
return TRUNCATED + chars.slice(chars.length - MAX_CHARS).join("");
}
const FINISHED = new Set(["completed", "failed", "cancelled"]);
/// The coalescing state of one tool call.
export class ToolStream {
private pending: ToolCallUpdate | null = null;
private skipped = 0;
private emittedStatus: ToolStatus | null = null;
private emittedTitle: string | null = null;
private emittedLength: number | null = null;
/// Takes one update and returns the patch to send now, or `null`. A
/// withheld update is merged into the next one rather than dropped, so
/// no field is ever lost.
push(update: ToolCallUpdate): ToolCallUpdate | null {
const merged = this.pending ? merge(this.pending, update) : update;
this.pending = null;
if (this.shouldEmit(merged)) {
this.skipped = 0;
if (merged.status !== undefined) this.emittedStatus = merged.status;
if (merged.title !== undefined) this.emittedTitle = merged.title;
this.emittedLength = progressLength(merged);
return merged;
}
this.skipped += 1;
this.pending = merged;
return null;
}
/// Releases anything still held, for the end of a run.
flush(): ToolCallUpdate | null {
this.skipped = 0;
const held = this.pending;
this.pending = null;
return held;
}
private shouldEmit(update: ToolCallUpdate): boolean {
// A finished call must always be reported, or it looks stuck.
if (update.status !== undefined && FINISHED.has(update.status)) return true;
// The first update of a call, and anything that changes what the row
// says rather than how much output it holds.
if (this.emittedLength === null) return true;
if (update.status !== undefined && update.status !== this.emittedStatus) return true;
if (update.title !== undefined && update.title !== this.emittedTitle) return true;
const grew = Math.abs(progressLength(update) - this.emittedLength) >= MIN_GROWTH;
return grew || this.skipped + 1 >= COALESCE_LIMIT;
}
}
/// How much text the update carries, which is what "growth" measures.
function progressLength(update: ToolCallUpdate): number {
let length = 0;
for (const item of update.content ?? []) {
if (item?.type === "text") length += item.text?.length ?? 0;
else if (item?.type === "terminal") length += item.output?.length ?? 0;
else if (item?.type === "diff") length += (item.diff?.length ?? 0) + (item.newText?.length ?? 0);
else if (item?.type === "image") length += item.data?.length ?? 0;
}
return length + (update.outputDelta?.length ?? 0);
}
/// Later values win, except `outputDelta`, which is cumulative text and so
/// is concatenated.
export function merge(into: ToolCallUpdate, from: ToolCallUpdate): ToolCallUpdate {
const out = { ...into };
if (from.status !== undefined) out.status = from.status;
if (from.title !== undefined) out.title = from.title;
if (from.kind !== undefined) out.kind = from.kind;
if (from.input !== undefined) out.input = from.input;
if (from.output !== undefined) out.output = from.output;
if (from.content !== undefined) out.content = from.content;
if (from.locations !== undefined) out.locations = from.locations;
if (from.outputDelta !== undefined) out.outputDelta = (out.outputDelta ?? "") + from.outputDelta;
return out;
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.9 KB | 6 permissions | Listed |
No comments yet.