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

history.ts7 KB
// Reads the transcripts Claude Code stores itself (the Rust `history.rs`).
//
// The CLI keeps one JSONL file per session under
// `<config dir>/projects/<encoded workspace>/<session id>.jsonl`, where the
// encoding replaces every character that is not a letter, a digit or a
// hyphen with a hyphen (`/private/tmp/a_b.c` becomes `-private-tmp-a-b-c`).
// The CLI records the resolved working directory; the host already names
// a workspace by its resolved path, so the path is encoded as given.
import * as Effect from "effect/Effect";
import * as wire from "./wire.ts";
import type { Files } from "./files.ts";
import type { SessionSummary } from "./types.ts";
import { join, mapLimit, readLines } from "./files.ts";
import { firstText, recordsToItems, timestamp } from "./records.ts";
import { attachSubagents } from "./subagents.ts";
import { attachWorkflows } from "./workflow.ts";

/// The folder name Claude Code uses for `workspace`.
export function encodeWorkspace(workspace: string) {
  return String(workspace).replace(/[^A-Za-z0-9-]/g, "-");
}

/// The folder of one workspace's transcripts in the configuration
/// directory `configDir`.
export function workspaceDir(configDir: string | null, workspace: string) {
  return configDir ? join(configDir, "projects", encodeWorkspace(workspace)) : null;
}

export function sessionFile(configDir: string | null, workspace: string, session: string) {
  const dir = workspaceDir(configDir, workspace);
  return dir ? join(dir, `${session}.jsonl`) : null;
}

/// Title, first and last activity of one transcript's records.
export function summarize(records: wire.Record[], id: string) {
  if (!records.length) return null;
  let title = null;
  let custom = null;
  let firstPrompt = null;
  let createdAt = null;
  let updatedAt = null;
  for (const record of records) {
    if (record?.type === "custom-title" && typeof record.customTitle === "string") custom = record.customTitle;
    else if (record?.type === "ai-title" && typeof record.aiTitle === "string") title = record.aiTitle;
    else if (record?.type === "user" && firstPrompt === null && record.isSidechain !== true)
      firstPrompt = firstText(record.message?.content);
    const time = timestamp(record);
    if (time !== null) {
      createdAt ??= time;
      updatedAt = time;
    }
  }
  let chosen = custom ?? title;
  if (chosen === null && firstPrompt !== null) {
    const line = (firstPrompt.split("\n").find((line) => line.trim()) ?? "").trim();
    const chars = [...line];
    chosen = chars.length > 80 ? `${chars.slice(0, 80).join("")}…` : line;
  }
  const out: SessionSummary = { id };
  if (chosen !== null) out.title = chosen;
  if (createdAt !== null) out.createdAt = createdAt;
  if (updatedAt !== null) out.updatedAt = updatedAt;
  return out;
}

/// Every session Claude Code stored for `workspace`, newest first.
export const listSessions = Effect.fn("Claude.listSessions")(function* (
  files: Files,
  configDir: string | null,
  workspace: string,
) {
  const dir = workspaceDir(configDir, workspace);
  if (!dir) return [];
  const entries = yield* files.list(dir);
  if (!entries) return [];
  const transcripts = entries.filter((entry) => entry.kind !== "dir" && entry.name.endsWith(".jsonl"));
  const sessions = yield* mapLimit(
    transcripts,
    8,
    Effect.fn(function* (entry) {
      return summarize(yield* readLines(files, join(dir, entry.name)), entry.name.slice(0, -".jsonl".length));
    }),
  );
  return sessions
    .filter((session) => session !== null)
    .sort((a, b) =>
      (b.updatedAt ?? "") > (a.updatedAt ?? "") ? 1 : (b.updatedAt ?? "") < (a.updatedAt ?? "") ? -1 : 0,
    );
});

/// The user messages of a transcript, sidechains aside.
export function userRecords(records: wire.Record[]) {
  return records.filter(
    (record) => record?.type === "user" && record.isSidechain !== true && record.message?.content !== undefined,
  );
}

/// How many user messages the stored transcript holds. Read just before a
/// prompt is sent, it is the index the CLI will give that prompt's record.
export const userRecordCount = Effect.fn("Claude.userRecordCount")(function* (
  files: Files,
  configDir: string | null,
  workspace: string,
  session: string,
) {
  const path = sessionFile(configDir, workspace, session);
  return path ? userRecords(yield* readLines(files, path)).length : 0;
});

/// Where a conversation goes back to when a user message is forgotten:
/// `{ fresh: true }` for the first message (nothing came before it, so the
/// conversation starts over), else `{ at: <the record just before it> }`.
/// `--resume-session-at` keeps the message it names, so the target is the
/// message's own parent.
export function rewindBefore(record: wire.Record) {
  return typeof record?.parentUuid === "string" ? { at: record.parentUuid } : { fresh: true };
}

/// How to rewind so that the `index`th user message and everything after
/// it are forgotten. `null` when there is no such message.
export const rewindTargetAt = Effect.fn("Claude.rewindTargetAt")(function* (
  files: Files,
  configDir: string | null,
  workspace: string,
  session: string,
  index: number,
) {
  const path = sessionFile(configDir, workspace, session);
  if (!path) return null;
  const record = userRecords(yield* readLines(files, path))[index];
  return record ? rewindBefore(record) : null;
});

/// The record to resume at so that the item `itemId` of the CLI's own file
/// and everything after it is forgotten. Assistant items are
/// `<uuid>:<block index>`; the record is the uuid. `null` for an item with
/// no parent or none at all: the rewind is refused, not half done.
export const rewindTarget = Effect.fn("Claude.rewindTarget")(function* (
  files: Files,
  configDir: string | null,
  workspace: string,
  session: string,
  itemId: string,
) {
  const path = sessionFile(configDir, workspace, session);
  if (!path) return null;
  const uuid = String(itemId).split(":")[0];
  const record = (yield* readLines(files, path)).find((record) => record?.uuid === uuid);
  return typeof record?.parentUuid === "string" ? record.parentUuid : null;
});

/// Parses one stored transcript into items, each subagent and workflow as
/// a task item with its own transcript inside.
export const readSession = Effect.fn("Claude.readSession")(function* (
  files: Files,
  configDir: string | null,
  workspace: string,
  session: string,
) {
  const path = sessionFile(configDir, workspace, session);
  return path ? yield* readFile(files, path) : [];
});

/// Parses the transcript at `path` (`<session id>.jsonl`), with the
/// subagents from the `<session id>/` folder beside it.
export const readFile = Effect.fn("Claude.readFile")(function* (files: Files, path: string) {
  // Old CLIs wrote subagents inline as sidechain records; the current one
  // writes them to their own files, read below.
  const records = (yield* readLines(files, path)).filter((record) => record?.isSidechain !== true);
  const items = recordsToItems(records);
  const sessionDir = path.replace(/\.jsonl$/, "");
  yield* attachSubagents(files, items, records, sessionDir);
  yield* attachWorkflows(files, items, records, sessionDir);
  return items;
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.