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

  • Provide agents agents.provideMediumAdds agents to the app.Provide the Claude Code agent and pass its tool calls to plugin tools
  • Run named programs processMediumStarts the listed programs.Run the Claude Code CLI (sessions, sign-in, `claude update`), and ask the npm installation that owns it about a newer versionPrograms: claudenpm
  • Read files fs.readMediumReads files in the listed places.Read Claude Code's sessions, settings and skills (in ~/.claude, other accounts' ~/.claude-* folders, the shared skills and the folder you choose in Settings), the project's .claude folder, the administrator's skill policy, and once, what the previous provider keptPlaces: ~/.claude/**~/.claude*/**~/.agents/skills/**the open workspaceits own data folder/Library/Application Support/ClaudeCode/managed-settings.json/etc/claude-code/managed-settings.json${settings.configDir}
  • Environment variables envMediumReads the listed environment variables.Find Claude Code's configuration directory, and the launch settings you set in CLAUDE_* variables (extra arguments, MCP servers, setting sources, appended system prompt, extra folders)Variables: HOMECLAUDE_CONFIG_DIRCLAUDE_EXTRA_ARGSCLAUDE_MCP_CONFIGCLAUDE_STRICT_MCP_CONFIGCLAUDE_SETTING_SOURCESCLAUDE_APPEND_SYSTEM_PROMPTCLAUDE_ADD_DIRS

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3128.7 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.