Official
claude
Claude Code agent provider: runs the Claude Code CLI headless for each account.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/claude@0.2.0
Permissions in 0.2.0
Files
agent.ts21.4 KB
// The Claude Code provider: one `claude` process per session, run headless
// over its `stream-json` protocol (the Rust `agent.rs`). See NOTES.md for
// the protocol facts behind every choice here.
import * as Scope from "effect/Scope";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import * as Result from "effect/Result";
import * as Effect from "effect/Effect";
import { Fs } from "convergence/effect";
import type { EffectAgent, PluginServices } from "convergence/effect";
import type { AgentInfo, AgentEvent, Values, UsageLimits } from "./types.ts";
import type { Files } from "./files.ts";
import type { Instance } from "./instances.ts";
import type { Catalog } from "./options.ts";
import type { SessionContext, Start, Additions, Prompt, Answer, Job } from "./session.ts";
import { ProviderError } from "./errors.ts";
import { runProcess } from "../sdk/effect.ts";
import {
which,
accountSummary,
installedVersion,
maintenance,
messageOf,
probeAccount,
signedOutMessage,
update,
} from "./account.ts";
import {
chosenConfigDir,
childEnv,
configDirOf,
continuationKey,
launchFromEnv,
loginEnv,
workflowSettings,
} from "./config.ts";
import { apiFiles, join, readJson } from "./files.ts";
import { listSessions, readSession, rewindTarget, rewindTargetAt } from "./history.ts";
import { ICON } from "./icon.ts";
import { agentId, displayName } from "./instances.ts";
import { ULTRACODE } from "./options.ts";
import { Session, splitId, starts } from "./session.ts";
import { listSkills } from "./skills.ts";
import { SessionStore } from "./state.ts";
interface SessionParams {
sessionId: string;
workspace?: string;
options?: Values;
tools?: Additions["tools"];
instructions?: string;
}
export const FAMILY = "claude";
const DESCRIPTION = "Anthropic's coding agent, run headless over its stream-json protocol.";
export class ClaudeAgent {
instance: Instance;
emit: (event: AgentEvent) => void;
files: Files;
store: SessionStore;
id: string;
name: string;
sessions: Map<string, Session>;
catalog: Catalog | null;
probing: Effect.Effect<Catalog, unknown, PluginServices> | null;
limits: UsageLimits | null;
usageWorkspace: string | null = null;
env: Record<string, string>;
scope: Scope.Scope;
jobs: Queue.Queue<Job>;
/// `api`: the plugin API. `instance`: the account. `emit`: sends an agent
/// event (set once the agent is registered). `files` and `store` are the
/// plugin's file reads and session state, replaceable in tests.
constructor({
instance,
scope,
jobs,
emit = () => {},
files = null,
store = null,
}: {
instance: Instance;
scope: Scope.Scope;
jobs: Queue.Queue<Job>;
emit?: (event: AgentEvent) => void;
files?: Files | null;
store?: SessionStore | null;
}) {
this.scope = scope;
this.jobs = jobs;
this.instance = instance;
this.emit = emit;
this.files = files ?? apiFiles();
this.store = store ?? new SessionStore();
this.id = agentId(instance);
this.name = displayName(instance);
this.sessions = new Map();
/// The model and command catalog, read once from a short-lived CLI
/// process so `list_options` works before any session exists.
this.catalog = null;
this.probing = null;
/// The last subscription limits any process reported.
this.limits = null;
/// The login environment last read (`config.ts`).
this.env = {};
}
/// The handlers the host calls, by `agent/<method>` name.
definition(): EffectAgent {
return {
id: this.id,
name: this.name,
initialize: this.initialize.bind(this),
list_options: this.listOptions.bind(this),
list_commands: this.listCommands.bind(this),
list_sessions: this.listSessions.bind(this),
read_session: this.readSession.bind(this),
list_skills: this.listSkills.bind(this),
create_session: this.createSession.bind(this),
resume_session: this.resumeSession.bind(this),
close_session: this.closeSession.bind(this),
fork_session: this.forkSession.bind(this),
prompt: this.prompt.bind(this),
cancel: this.cancel.bind(this),
cancel_task: this.cancelTask.bind(this),
set_option: this.setOption.bind(this),
respond_to_approval: this.respondToApproval.bind(this),
respond_to_question: this.respondToQuestion.bind(this),
rollback: this.rollback.bind(this),
compact: this.compact.bind(this),
usage_limits: this.usageLimits.bind(this),
update: this.update.bind(this),
authenticate: this.authenticate.bind(this),
logout: this.logout.bind(this),
};
}
background = Effect.fn("Claude.background")(function* (this: ClaudeAgent) {
yield* Stream.runForEach(Stream.fromQueue(this.jobs), (job) => job.pipe(Effect.forkIn(this.scope))).pipe(
Effect.forkIn(this.scope),
);
yield* Effect.addFinalizer(() => this.shutdown().pipe(Effect.orDie));
});
/// Reads the login environment again: the launch knobs apply to the
/// next session without a restart.
refreshEnv = Effect.fn("Claude.refreshEnv")(function* (this: ClaudeAgent) {
this.env = yield* loginEnv();
return this.env;
});
configDir() {
return configDirOf(this.instance, this.env);
}
/// The configuration folder, once it is sure the plugin may read it. A
/// folder no grant covers (an account's own folder outside `~/.claude*`,
/// or `CLAUDE_CONFIG_DIR` from the login environment with the setting
/// empty) would read as an empty history: no sessions, nothing to rewind
/// to. Say so instead.
readableConfigDir = Effect.fn("Claude.readableConfigDir")(function* (this: ClaudeAgent) {
const dir = this.configDir();
if (!dir) return dir;
const read = yield* Effect.result((yield* Fs).stat(join(dir, "projects")));
if (Result.isFailure(read) && read.failure._tag === "PermissionNotGranted") {
const account = this.instance.id ? `the ${this.instance.id} account` : "the default account";
return yield* new ProviderError({
message: `Claude Code keeps ${account}'s sessions in ${dir}, which this plugin may not read: choose that folder as the Configuration folder in Settings > Claude Code, or keep the account in a folder whose name starts with ~/.claude (as ~/.claude-work)`,
});
}
// A missing projects folder means the CLI has not written history yet.
return dir;
});
/// What a session needs from its agent.
context(emit: (event: AgentEvent) => void = (event) => this.emit(event)): SessionContext {
return {
scope: this.scope,
jobs: this.jobs,
files: this.files,
store: this.store,
instance: this.instance,
env: this.env,
agentId: this.id,
emit,
};
}
session(id: string) {
const session = this.sessions.get(id);
if (!session) throw new Error(`unknown session ${id}`);
return session;
}
/// Reads the catalog with a throwaway session that is never prompted. The
/// same process answers the subscription limits, so the host has them
/// before the user sends anything.
loadCatalog = Effect.fn("Claude.loadCatalog")(function* (this: ClaudeAgent, workspace: string) {
this.usageWorkspace = workspace;
if (this.catalog) return this.catalog;
if (!this.probing)
this.probing = yield* Effect.cached(
Effect.gen({ self: this }, function* () {
yield* this.refreshEnv();
const probe = yield* Session.create(
this.context(() => {}),
{ id: crypto.randomUUID(), workspace, probe: true, launch: launchFromEnv(this.env) },
);
return yield* Effect.gen({ self: this }, function* () {
yield* probe.start();
const limits = yield* probe.usageLimits().pipe(
// Limits are optional during the catalog health check.
Effect.catchTag("ProviderError", () => Effect.succeed(null)),
);
if (limits) this.limits = limits;
this.catalog = probe.catalog;
return probe.catalog;
}).pipe(Effect.ensuring(probe.shutdown().pipe(Effect.orDie)));
}).pipe(
Effect.ensuring(
Effect.sync(() => {
this.probing = null;
}),
),
),
);
return yield* this.probing;
});
/// Starts a session's process and keeps it. The old process of the same
/// session is stopped first: two processes writing one conversation file
/// would race over the transcript.
open = Effect.fn("Claude.open")(function* (
this: ClaudeAgent,
id: string,
workspace: string,
values: Values,
start: Start,
additions: Additions,
) {
this.usageWorkspace = workspace;
const previous = this.sessions.get(id);
if (previous) {
this.sessions.delete(id);
yield* previous.shutdown();
}
yield* this.refreshEnv();
const session = yield* Session.create(this.context(), {
id,
workspace,
values,
start,
launch: launchFromEnv(this.env),
additions,
});
this.sessions.set(id, session);
yield* session.start().pipe(
Effect.onError(() =>
Effect.sync(() => {
if (this.sessions.get(id) === session) this.sessions.delete(id);
}),
),
);
return session;
});
// --- agent methods ----------------------------------------------------------
initialize = Effect.fn("Claude.initialize")(function* (this: ClaudeAgent) {
yield* this.refreshEnv();
const info = (
status: AgentInfo["status"],
version: string | null,
description: string,
maintained: AgentInfo["maintenance"] | null,
): AgentInfo => {
const out: AgentInfo = {
id: this.id,
name: this.name,
description,
icon: ICON,
capabilities: {
reasoning: true,
images: true,
approvals: true,
questions: true,
sessionList: true,
sessionHistory: true,
resume: true,
slashCommands: true,
// Streaming `priority: next` is native non-interrupting pickup.
steer: true,
cancel: true,
skills: true,
rollback: true,
fork: true,
compact: true,
usageLimits: true,
subagents: true,
// `stop_task` stops one subagent; the parent's Stop still stops
// them all.
cancelTask: true,
},
// The CLI owns the browser flow; Convergence starts it so the user
// never has to find a terminal.
authMethods: [
{
id: "anthropic",
name: "Sign in to Anthropic",
description: "Runs `claude auth login` for this configuration directory",
},
],
status,
family: FAMILY,
continuationKey: continuationKey(this.instance, this.env),
};
if (version) out.version = version;
if (maintained) out.maintenance = maintained;
if (this.limits) out.usageLimits = this.limits;
return out;
};
const keywords = yield* this.promptKeywords();
const withKeywords = (out: AgentInfo) => (keywords.length ? { ...out, promptKeywords: keywords } : out);
const found = yield* which();
if (!found.path)
return withKeywords(
info({ state: "unavailable", message: "the claude CLI is not on PATH" }, null, DESCRIPTION, null),
);
const version = yield* Effect.scoped(installedVersion());
const maintained = (yield* Effect.scoped(maintenance(version))).out;
// The plugin drives this same binary, so an `auth status` that cannot
// run means a session would not run either.
const probe = yield* Effect.result(Effect.scoped(probeAccount(childEnv(this.instance, this.env))));
if (Result.isFailure(probe))
return withKeywords(
info({ state: "unavailable", message: probe.failure.message }, version, DESCRIPTION, maintained),
);
const account = probe.success;
const status: AgentInfo["status"] = account.loggedIn
? { state: "ready" }
: { state: "auth_required", message: signedOutMessage(chosenConfigDir(this.instance, this.env)) };
const summary = accountSummary(account);
return withKeywords(info(status, version, summary ? `${DESCRIPTION} ${summary}` : DESCRIPTION, maintained));
});
/// The CLI finds the word itself and turns that one turn into a
/// workflow; the composer only tells the user it will.
promptKeywords = Effect.fn("Claude.promptKeywords")(function* (this: ClaudeAgent) {
const dir = this.configDir();
const settings = workflowSettings(dir ? yield* readJson(this.files, join(dir, "settings.json")) : null);
return settings.keyword
? [{ word: ULTRACODE, label: "Ultracode", description: "This turn runs as a dynamic workflow." }]
: [];
});
listOptions = Effect.fn("Claude.listOptions")(function* (this: ClaudeAgent, { workspace }: { workspace?: string }) {
return { options: (yield* this.loadCatalog(String(workspace ?? ""))).options({}) };
});
listCommands = Effect.fn("Claude.listCommands")(function* (this: ClaudeAgent, { sessionId }: { sessionId: string }) {
return { commands: this.session(sessionId).commands() };
});
listSessions = Effect.fn("Claude.listSessions")(function* (this: ClaudeAgent, { workspace }: { workspace?: string }) {
yield* this.refreshEnv();
return { sessions: yield* listSessions(this.files, yield* this.readableConfigDir(), String(workspace ?? "")) };
});
readSession = Effect.fn("Claude.readSession")(function* (
this: ClaudeAgent,
{ workspace, sessionId }: { workspace?: string; sessionId: string },
) {
yield* this.refreshEnv();
const live = this.sessions.get(sessionId);
const cli = live ? live.cli : yield* this.store.cliIdOf(sessionId);
return {
items: yield* readSession(
this.files,
yield* this.readableConfigDir(),
String(workspace ?? live?.workspace ?? ""),
cli,
),
};
});
listSkills = Effect.fn("Claude.listSkills")(function* (this: ClaudeAgent, { workspace }: { workspace?: string }) {
yield* this.refreshEnv();
const commands = this.catalog ? this.catalog.commands : [];
return { skills: yield* listSkills(this.files, String(workspace ?? ""), this.configDir(), commands) };
});
createSession = Effect.fn("Claude.createSession")(function* (
this: ClaudeAgent,
params: Omit<SessionParams, "sessionId">,
) {
const id = crypto.randomUUID();
yield* this.open(id, String(params.workspace ?? ""), params.options ?? {}, starts.new(), {
tools: params.tools,
instructions: params.instructions,
});
return { sessionId: id };
});
resumeSession = Effect.fn("Claude.resumeSession")(function* (this: ClaudeAgent, params: SessionParams) {
yield* this.open(params.sessionId, String(params.workspace ?? ""), params.options ?? {}, starts.resume(), {
tools: params.tools,
instructions: params.instructions,
});
return {};
});
closeSession = Effect.fn("Claude.closeSession")(function* (this: ClaudeAgent, { sessionId }: { sessionId: string }) {
const session = this.sessions.get(sessionId);
if (session) {
this.sessions.delete(sessionId);
yield* session.shutdown();
}
return {};
});
prompt = Effect.fn("Claude.prompt")(function* (
this: ClaudeAgent,
{ sessionId, input }: { sessionId: string; input?: Prompt },
) {
return yield* this.session(sessionId).prompt(input ?? {});
});
cancel = Effect.fn("Claude.cancel")(function* (this: ClaudeAgent, { sessionId }: { sessionId: string }) {
yield* this.session(sessionId).cancel();
return {};
});
cancelTask = Effect.fn("Claude.cancelTask")(function* (
this: ClaudeAgent,
{ sessionId, taskId }: { sessionId: string; taskId: string },
) {
yield* this.session(sessionId).cancelTask(taskId);
return {};
});
setOption = Effect.fn("Claude.setOption")(function* (
this: ClaudeAgent,
{ sessionId, optionId, value }: { sessionId: string; optionId: string; value: unknown },
) {
return { options: yield* this.session(sessionId).setOption(optionId, value) };
});
respondToApproval = Effect.fn("Claude.respondToApproval")(function* (
this: ClaudeAgent,
{ approvalId, optionId }: { approvalId: string; optionId: string },
) {
const parts = splitId(approvalId);
if (!parts) return yield* new ProviderError({ message: "malformed approval id" });
this.session(parts[0]).respondToApproval(approvalId, optionId);
return {};
});
respondToQuestion = Effect.fn("Claude.respondToQuestion")(function* (
this: ClaudeAgent,
{ questionId, answer }: { questionId: string; answer?: Answer },
) {
const parts = splitId(questionId);
if (!parts) return yield* new ProviderError({ message: "malformed question id" });
this.session(parts[0]).respondToQuestion(questionId, answer ?? { values: {}, cancelled: false });
return {};
});
/// Rewinds the conversation so `itemId` and everything after it is
/// forgotten: the session's process starts again with
/// `--resume-session-at` the record before it, or, when nothing came
/// before it, on a fresh conversation. The host's own id is found by the
/// user record its prompt became; an id from the CLI's file (a resumed
/// conversation) by its uuid.
rollback = Effect.fn("Claude.rollback")(function* (
this: ClaudeAgent,
{ sessionId, itemId }: { sessionId: string; itemId: string },
) {
const current = this.session(sessionId);
const dir = yield* this.readableConfigDir();
const index = current.recordIndexOf(itemId);
let rewind = index !== null ? yield* rewindTargetAt(this.files, dir, current.workspace, current.cli, index) : null;
if (!rewind) {
const at = yield* rewindTarget(this.files, dir, current.workspace, current.cli, itemId);
if (at !== null) rewind = { at };
}
if (!rewind)
return yield* new ProviderError({
message: `the conversation has nothing recorded before ${itemId}, so it cannot be rewound to that point`,
});
const start = "fresh" in rewind ? starts.new() : starts.resumeAt(rewind.at);
yield* this.open(sessionId, current.workspace, current.values, start, current.additions);
return {};
});
/// A fork keeps the original's settings, because it is the same
/// conversation taken in another direction; the tools and instructions
/// are the ones the host sent for the copy.
forkSession = Effect.fn("Claude.forkSession")(function* (this: ClaudeAgent, params: SessionParams) {
const origin = this.sessions.get(params.sessionId);
const values = origin ? origin.values : (params.options ?? {});
const from = origin ? origin.cli : yield* this.store.cliIdOf(params.sessionId);
const id = crypto.randomUUID();
yield* this.open(id, String(params.workspace ?? ""), values, starts.fork(from), {
tools: params.tools,
instructions: params.instructions,
});
return { sessionId: id };
});
compact = Effect.fn("Claude.compact")(function* (this: ClaudeAgent, { sessionId }: { sessionId: string }) {
yield* this.session(sessionId).compact();
return {};
});
/// Re-initialize a prompt-free process with the current configuration. An
/// existing process's account snapshot may predate login/config changes;
/// relabelling its cached permission with today's auth identity is unsafe.
usageLimits = Effect.fn("Claude.usageLimits")(function* (this: ClaudeAgent) {
if (this.usageWorkspace === null) return { limits: null };
yield* this.refreshEnv();
const probe = yield* Session.create(
this.context(() => {}),
{
id: crypto.randomUUID(),
workspace: this.usageWorkspace,
probe: true,
launch: launchFromEnv(this.env),
},
);
return yield* Effect.gen({ self: this }, function* () {
yield* probe.start();
this.limits = yield* probe.usageLimits();
return { limits: this.limits };
}).pipe(Effect.ensuring(probe.shutdown().pipe(Effect.orDie)));
});
/// Runs the CLI's own sign-in for this configuration directory. It opens
/// a browser and waits, so it gets no timeout: running it here rather
/// than in a terminal is the point, since a second account has its own
/// directory and a login typed in the wrong shell lands in the wrong one.
authenticate = Effect.fn("Claude.authenticate")(function* (this: ClaudeAgent) {
yield* this.refreshEnv();
const result = yield* Effect.scoped(
runProcess("claude", ["auth", "login"], { env: childEnv(this.instance, this.env) }),
);
if (result.code !== 0) return yield* new ProviderError({ message: messageOf(result) });
this.catalog = null;
return {};
});
logout = Effect.fn("Claude.logout")(function* (this: ClaudeAgent) {
yield* this.refreshEnv();
// Stop every session first: a resumed one would keep using the account
// that was just signed out.
yield* this.shutdown();
const result = yield* Effect.scoped(
runProcess("claude", ["auth", "logout"], { env: childEnv(this.instance, this.env) }),
);
if (result.code !== 0) return yield* new ProviderError({ message: messageOf(result) });
this.catalog = null;
return {};
});
update = Effect.fn("Claude.update")(function* (this: ClaudeAgent) {
yield* this.refreshEnv();
yield* Effect.scoped(update(childEnv(this.instance, this.env)));
return {};
});
shutdown = Effect.fn("Claude.shutdown")(function* (this: ClaudeAgent) {
const sessions = [...this.sessions.values()];
this.sessions.clear();
yield* Effect.all(
sessions.map((session) => session.shutdown()),
{ concurrency: "unbounded" },
);
});
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 128.7 KB | 4 permissions | Listed |
No comments yet.