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
terminal.ts7.9 KB
// ACP client terminals: scoped broker children with interleaved output.
// Buffers keep the newest UTF-8 bytes; releasing a terminal rejects its waits.
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Data from "effect/Data";
import * as Exit from "effect/Exit";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as z from "zod";
import { Process } from "convergence/effect";
import type { ChildProcess } from "convergence";
import { newId } from "../sdk/agent.ts";
import { errorMessage } from "../sdk/errors.ts";
import type { ProcessExit } from "../sdk/process.ts";
export const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
const encoder = new TextEncoder();
export class TerminalFailed extends Data.TaggedError("TerminalFailed")<{ readonly message: string }> {}
const failed = (cause: unknown) => new TerminalFailed({ message: errorMessage(cause) });
export class OutputBuffer {
limit: number;
text = "";
truncated = false;
constructor(limit?: unknown) {
this.limit = typeof limit === "number" && Number.isInteger(limit) && limit >= 0 ? limit : DEFAULT_OUTPUT_LIMIT;
}
push(more: string) {
this.text += more;
if (this.text.length * 3 <= this.limit) return;
const bytes = encoder.encode(this.text);
if (bytes.length <= this.limit) return;
let start = bytes.length - this.limit;
while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1;
this.text = new TextDecoder().decode(bytes.subarray(start));
this.truncated = true;
}
}
export function shellLine(command: string, args?: readonly string[]) {
return [command, ...(args ?? [])].join(" ");
}
export const terminalRequest = z.object({
sessionId: z.unknown().optional(),
command: z.unknown().optional(),
args: z.array(z.unknown()).catch([]),
cwd: z.unknown().optional(),
env: z.array(z.unknown()).catch([]),
outputByteLimit: z.unknown().optional(),
});
export interface TerminalRequest {
sessionId?: unknown;
command?: unknown;
args?: unknown;
cwd?: unknown;
env?: unknown;
outputByteLimit?: unknown;
}
interface Terminal {
session: string;
command: string;
cwd: string | null;
output: OutputBuffer;
child: ChildProcess;
exit: ProcessExit | null;
released: boolean;
exited: Deferred.Deferred<ProcessExit, TerminalFailed>;
scope: Scope.Closeable;
}
export class Terminals {
terminals = new Map<string, Terminal>();
scope: Scope.Scope;
constructor(scope: Scope.Scope) {
this.scope = scope;
}
create = Effect.fn("Terminals.create")(function* (this: Terminals, input: TerminalRequest) {
const request = terminalRequest.parse(input);
const command = String(request.command ?? "");
if (!command) return yield* new TerminalFailed({ message: "terminal/create needs a command" });
const args = request.args.map(String);
const env: Record<string, string> = {};
for (const raw of request.env) {
const variable = z.record(z.string(), z.unknown()).safeParse(raw).data ?? {};
if (typeof variable.name === "string") env[variable.name] = String(variable.value ?? "");
}
const cwd = typeof request.cwd === "string" && request.cwd ? request.cwd : undefined;
const process = yield* Process;
const scope = yield* Scope.fork(this.scope, "sequential");
const child = yield* process.spawn(command, args, { cwd, env }).pipe(
Scope.provide(scope),
Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
Effect.fail(new TerminalFailed({ message: `could not run ${command}: ${error.message}` })),
),
);
// Commands receive no input; closed input is harmless.
yield* Effect.tryPromise({ try: () => child.stdin.close(), catch: failed }).pipe(
Effect.catchTag("TerminalFailed", () => Effect.void),
Effect.forkIn(scope, { startImmediately: true }),
);
const terminal: Terminal = {
session: String(request.sessionId ?? ""),
command: shellLine(command, args),
cwd: cwd ?? null,
output: new OutputBuffer(request.outputByteLimit),
child,
exit: null,
released: false,
exited: yield* Deferred.make<ProcessExit, TerminalFailed>(),
scope,
};
const read = Effect.fn("Terminals.read")(function* (stream: ChildProcess["stdout"]) {
const decoder = new TextDecoder();
// A broken pipe only ends the output.
yield* Stream.runForEach(Stream.fromAsyncIterable(stream, failed), (chunk) =>
Effect.sync(() => {
terminal.output.push(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }));
}),
).pipe(
Effect.tap(() =>
Effect.sync(() => {
const rest = decoder.decode();
if (rest) terminal.output.push(rest);
}),
),
Effect.catchTag("TerminalFailed", () => Effect.void),
);
});
yield* Effect.all(
[Effect.tryPromise({ try: () => child.exited, catch: failed }), read(child.stdout), read(child.stderr)],
{ concurrency: "unbounded" },
).pipe(
Effect.flatMap(([status]) =>
Effect.sync(() => {
terminal.exit = { code: status.code ?? null, signal: status.signal ?? null };
return terminal.exit;
}),
),
Effect.flatMap((status) => Deferred.succeed(terminal.exited, status)),
Effect.catchTag("TerminalFailed", (error) => Deferred.fail(terminal.exited, error)),
Effect.forkIn(scope),
);
const id = newId("acp-terminal");
this.terminals.set(id, terminal);
return id;
});
get(id: string) {
const terminal = this.terminals.get(id);
if (!terminal) throw new TerminalFailed({ message: `unknown terminal ${id}` });
return terminal;
}
output(id: string) {
const terminal = this.get(id);
return { output: terminal.output.text, truncated: terminal.output.truncated, exit: terminal.exit };
}
wait = Effect.fn("Terminals.wait")(function* (this: Terminals, id: string) {
const terminal = yield* Effect.try({ try: () => this.get(id), catch: failed });
return terminal.exit ?? (yield* Deferred.await(terminal.exited));
});
kill = Effect.fn("Terminals.kill")(function* (this: Terminals, id: string) {
const terminal = yield* Effect.try({ try: () => this.get(id), catch: failed });
if (terminal.exit === null)
yield* Effect.tryPromise({ try: () => terminal.child.kill("SIGKILL"), catch: failed }).pipe(
Effect.catchTag("TerminalFailed", () => Effect.void),
);
});
release = Effect.fn("Terminals.release")(function* (this: Terminals, id: string) {
const terminal = this.terminals.get(id);
if (!terminal) return;
this.terminals.delete(id);
terminal.released = true;
if (terminal.exit === null) {
yield* Deferred.fail(
terminal.exited,
new TerminalFailed({ message: `terminal ${id} was released before it exited` }),
);
yield* Effect.tryPromise({ try: () => terminal.child.kill("SIGKILL"), catch: failed }).pipe(
Effect.catchTag("TerminalFailed", () => Effect.void),
);
}
yield* Scope.close(terminal.scope, Exit.void);
});
releaseSession = Effect.fn("Terminals.releaseSession")(function* (this: Terminals, session: string) {
for (const [id, terminal] of [...this.terminals]) if (terminal.session === session) yield* this.release(id);
});
releaseAll = Effect.fn("Terminals.releaseAll")(function* (this: Terminals) {
for (const id of [...this.terminals.keys()]) yield* this.release(id);
});
views(session: string) {
const views = new Map<string, { command: string; cwd: string | null; output: string; exitCode: number | null }>();
for (const [id, terminal] of this.terminals) {
if (terminal.session !== session) continue;
views.set(id, {
command: terminal.command,
cwd: terminal.cwd,
output: terminal.output.text,
exitCode: terminal.exit?.code ?? null,
});
}
return views;
}
}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.