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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 128.7 KB | 4 permissions | Listed |
No comments yet.