import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { type AgentPlugin, type AgentTool, type AgentToolContext, ClineCore, createTool, } from "@cline/core"; import YAML from "yaml"; import { z } from "zod"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type SessionManager = ClineCore; /** Minimal plugin host interface injected by the runtime via globalThis. */ interface ClinePluginHost { emitEvent?: (name: string, payload?: unknown) => void; } declare global { var __clinePluginHost: ClinePluginHost | undefined; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); const BUNDLED_AGENTS_DIR = join(MODULE_DIR, "agents"); const BUNDLED_SKILLS_DIR = join(MODULE_DIR, "skills"); function resolveDefaultHomeDir(): string { const envHome = process?.env?.HOME?.trim(); if (envHome && envHome !== "~") { return envHome; } const envUserProfile = process?.env?.USERPROFILE?.trim(); if (envUserProfile) { return envUserProfile; } const envHomeDrive = process?.env?.HOMEDRIVE?.trim(); const envHomePath = process?.env?.HOMEPATH?.trim(); if (envHomeDrive && envHomePath) { return `${envHomeDrive}${envHomePath}`; } return "~"; } function resolveClineDirPath(): string { const explicitDir = process.env.CLINE_DIR?.trim(); if (explicitDir) { return explicitDir; } return join(resolveDefaultHomeDir(), ".cline"); } function resolveClineDataDirPath(): string { const explicitDir = process.env.CLINE_DATA_DIR?.trim(); if (explicitDir) { return explicitDir; } return join(resolveClineDirPath(), "data"); } function resolveGlobalAgentsDirPath(): string { return join(resolveClineDataDirPath(), "settings", "agents"); } const HANDOFFS_DIR = join( resolveClineDataDirPath(), "plugins", "subagents", "handoffs", ); const GLOBAL_SKILLS_DIR = join(resolveClineDataDirPath(), "settings", "skills"); // Agent and skill definitions live in the `agents/` and `skills/` // directories alongside this file. They are loaded at runtime from disk. /** Safe identifier pattern for conversation IDs used in filesystem paths. */ const SAFE_ID_RE = /^[A-Za-z0-9_-]+$/; const HANDOFF_PATH_ALLOWED_RE = /^[A-Za-z0-9._/-]+$/; const HANDOFF_PATH_MAX_LENGTH = 240; const envOr = (key: string, fallback: string): string => process.env[key]?.trim() || fallback; const DEFAULT_PROVIDER_ID = envOr("CLINE_SUBAGENT_PROVIDER_ID", "cline"); const DEFAULT_MODEL_ID = envOr( "CLINE_SUBAGENT_MODEL_ID", "anthropic/claude-sonnet-4.6", ); type SubagentBackendMode = "auto" | "hub" | "local"; const DEFAULT_BACKEND_MODE = envOr("CLINE_SUBAGENTS_BACKEND_MODE", "auto"); const DEFAULT_AGENT_PRESET = envOr("CLINE_SUBAGENT_DEFAULT_PRESET", "phantom"); // --------------------------------------------------------------------------- // Agent & Skill Definitions // --------------------------------------------------------------------------- interface AgentDefinition { name: string; description?: string; providerId?: string; modelId?: string; systemPrompt: string; cwd?: string; maxIterations?: number; source: "bundled" | "global" | "project"; } interface SkillDefinition { name: string; description?: string; content: string; source: "bundled" | "global" | "project"; } interface RunningSubagent { sessionId: string; parentSessionId?: string; name: string; task: string; agent?: string; startedAt: number; status: "running" | "completed" | "failed"; resultText?: string; error?: string; finishReason?: string; completedAt?: number; } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- const subagents = new Map(); let sessionManagerPromise: Promise | undefined; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Cast a fully-typed tool to the `AgentTool` expected by * the plugin API's `registerTool` method. This is safe at runtime because the * registry only invokes `execute` with validated input that matches the * tool's `inputSchema`. */ function toRegisteredTool( tool: AgentTool, ): AgentTool { return tool as AgentTool; } function optStr(v: unknown): string | undefined { return typeof v === "string" && v.trim() ? v.trim() : undefined; } function optInt(v: unknown): number | undefined { return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined; } function parseFrontmatter(md: string): { data: Record; body: string; } { const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) return { data: {}, body: md.trim() }; try { const frontmatter = m[1] ?? ""; const body = m[2] ?? ""; const parsed = YAML.parse(frontmatter); return { data: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}, body: body.trim(), }; } catch { // Malformed YAML frontmatter — treat as plain markdown with no metadata. return { data: {}, body: md.trim() }; } } function readMarkdownDir( dirPath: string, source: AgentDefinition["source"], ): Array<{ name: string; data: Record; body: string; source: typeof source; }> { if (!existsSync(dirPath)) return []; const results: Array<{ name: string; data: Record; body: string; source: typeof source; }> = []; for (const entry of readdirSync(dirPath, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith(".md")) continue; try { const { data, body } = parseFrontmatter( readFileSync(join(dirPath, entry.name), "utf8"), ); if (!body) continue; const name = optStr(data.name) ?? entry.name.replace(/\.md$/, ""); results.push({ name, data, body, source }); } catch {} } return results; } function readAgentDefinitions(baseCwd: string): AgentDefinition[] { const dirs: Array<{ path: string; source: AgentDefinition["source"] }> = [ { path: BUNDLED_AGENTS_DIR, source: "bundled" }, { path: resolveGlobalAgentsDirPath(), source: "global" }, { path: join(baseCwd, ".cline", "agents"), source: "project" }, ]; const defs = new Map(); for (const { path, source } of dirs) { for (const entry of readMarkdownDir(path, source)) { defs.set(entry.name, { name: entry.name, description: optStr(entry.data.description), providerId: optStr(entry.data.providerId), modelId: optStr(entry.data.modelId), systemPrompt: entry.body, cwd: optStr(entry.data.cwd), maxIterations: optInt(entry.data.maxIterations), source: entry.source, }); } } return [...defs.values()].sort((a, b) => a.name.localeCompare(b.name)); } function readSkillDefinitions(baseCwd: string): SkillDefinition[] { const dirs: Array<{ path: string; source: SkillDefinition["source"] }> = [ { path: BUNDLED_SKILLS_DIR, source: "bundled" }, { path: GLOBAL_SKILLS_DIR, source: "global" }, { path: join(baseCwd, ".cline", "skills"), source: "project" }, ]; const defs = new Map(); for (const { path, source } of dirs) { for (const entry of readMarkdownDir(path, source)) { defs.set(entry.name, { name: entry.name, description: optStr(entry.data.description), content: entry.body, source: entry.source, }); } } return [...defs.values()].sort((a, b) => a.name.localeCompare(b.name)); } function parentSessionId(ctx: AgentToolContext): string | undefined { const id = ctx.metadata?.sessionId; return typeof id === "string" && id.trim() ? id.trim() : undefined; } function sanitizeConversationId(conversationId: string): string { const trimmed = conversationId.trim(); if (!trimmed || !SAFE_ID_RE.test(trimmed)) { throw new Error(`Invalid conversation ID for filesystem use: "${trimmed}"`); } return trimmed; } function handoffsDir(ctx: AgentToolContext): string { const conversationId = ctx.conversationId ?? parentSessionId(ctx); if (!conversationId) { throw new Error("Missing conversation ID for handoff storage"); } const safeId = sanitizeConversationId(conversationId); const dir = join(HANDOFFS_DIR, safeId); mkdirSync(dir, { recursive: true }); return dir; } function resolveHandoffPath( ctx: AgentToolContext, relativePath: string, ): string { const handoffPath = validateHandoffRelativePath(relativePath); const dir = handoffsDir(ctx); const resolved = resolve(dir, handoffPath); const pathFromHandoffsDir = relative(dir, resolved); if ( !pathFromHandoffsDir || pathFromHandoffsDir === ".." || pathFromHandoffsDir.startsWith(`..${sep}`) || isAbsolute(pathFromHandoffsDir) ) { throw new Error(`Handoff path escapes directory: ${relativePath}`); } return resolved; } function validateHandoffRelativePath(relativePath: string): string { const trimmed = relativePath.trim(); if (!trimmed) { throw new Error("Handoff path must not be empty"); } if (trimmed.length > HANDOFF_PATH_MAX_LENGTH) { throw new Error( `Handoff path must be ${HANDOFF_PATH_MAX_LENGTH} characters or fewer`, ); } if (trimmed.startsWith("/")) { throw new Error(`Handoff path must be relative: ${relativePath}`); } if (!HANDOFF_PATH_ALLOWED_RE.test(trimmed)) { throw new Error( "Use a relative file path with letters, numbers, '.', '_', '-', or '/'.", ); } if (trimmed.split("/").includes("..")) { throw new Error(`Handoff path must not contain '..': ${relativePath}`); } return trimmed; } function emitSteer(sessionId: string | undefined, prompt: string): void { if (sessionId && prompt.trim()) { globalThis.__clinePluginHost?.emitEvent?.("steer_message", { sessionId, prompt, }); } } async function getSessionManager(): Promise { sessionManagerPromise ??= ClineCore.create({ backendMode: resolveSubagentBackendMode(DEFAULT_BACKEND_MODE), }).catch((err) => { // Clear the cached promise so subsequent calls can retry. sessionManagerPromise = undefined; throw err; }); return sessionManagerPromise; } function resolveSubagentBackendMode(value: string): SubagentBackendMode { switch (value) { case "auto": case "hub": case "local": return value; default: return "auto"; } } function extractLastAssistantText( messages: Array<{ role?: string; content?: unknown }>, ): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue; const text = (msg.content as Array<{ type?: string; text?: unknown }>) .filter((b) => b?.type === "text" && typeof b.text === "string") .map((b) => b.text as string) .join("") .trim(); if (text) return text; } return ""; } function elapsed(start: number, end = Date.now()): string { const s = Math.max(0, Math.floor((end - start) / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; } function steerPrompt(subagent: RunningSubagent): string { const time = elapsed(subagent.startedAt, subagent.completedAt ?? Date.now()); const header = subagent.status === "completed" ? `Sub-agent "${subagent.name}" completed (${time}).` : `Sub-agent "${subagent.name}" failed (${time}).`; const body = subagent.resultText?.trim() || subagent.error?.trim() || ""; return [header, body, `Session ID: ${subagent.sessionId}`] .filter(Boolean) .join("\n\n"); } async function runSubagentTurn( subagent: RunningSubagent, message: string, steer: boolean, ): Promise { try { const mgr = await getSessionManager(); const result = await mgr.send({ sessionId: subagent.sessionId, prompt: message, }); const messages = await mgr.readMessages(subagent.sessionId); subagent.status = "completed"; subagent.finishReason = result?.finishReason; subagent.resultText = result?.text?.trim() || extractLastAssistantText(messages) || ""; subagent.error = undefined; subagent.completedAt = Date.now(); } catch (err) { subagent.status = "failed"; subagent.error = err instanceof Error ? err.message : String(err); subagent.completedAt = Date.now(); } if (steer) emitSteer(subagent.parentSessionId, steerPrompt(subagent)); } // --------------------------------------------------------------------------- // Schemas // --------------------------------------------------------------------------- const NonEmptyText = z.string().trim().min(1); const HandoffPathInput = z .string() .trim() .min(1) .max(240) .describe( "Relative file path using letters, numbers, '.', '_', '-', or '/'. Must not be absolute or contain '..' segments.", ); const StartSubagentInput = z .object({ label: NonEmptyText.describe( "Short display label for this run, used in status and completion messages.", ), task: NonEmptyText.describe( "Primary task for the subagent. This becomes its first user message.", ), preset: NonEmptyText.optional().describe( `Optional agent preset name from list_agent_presets. Defaults to "${DEFAULT_AGENT_PRESET}" when omitted.`, ), instructions: NonEmptyText.optional().describe( "Extra system instructions appended after the preset prompt. Optional when using a preset.", ), providerId: NonEmptyText.optional().describe( "Optional provider override. Defaults to the preset or plugin default.", ), modelId: NonEmptyText.optional().describe( "Optional model override. Defaults to the preset or plugin default.", ), workingDirectory: NonEmptyText.optional().describe( "Optional working directory, resolved from the plugin base cwd.", ), maxIterations: z .number() .int() .min(1) .optional() .describe("Optional hard limit for the subagent turn loop."), notifyParent: z .boolean() .optional() .describe( "When true or omitted, send the final outcome back to the parent session.", ), }) .strict(); const MessageSubagentInput = z .object({ sessionId: NonEmptyText.describe("Existing subagent session ID."), prompt: NonEmptyText.describe( "Follow-up user message to send to the subagent.", ), notifyParent: z .boolean() .optional() .describe( "When true or omitted, send the final outcome back to the parent session.", ), }) .strict(); const GetSubagentInput = z .object({ sessionId: NonEmptyText.describe("Subagent session ID."), }) .strict(); const SaveHandoffInput = z .object({ path: HandoffPathInput.describe( "Relative path inside the conversation handoff store, for example 'research/notes.md'.", ), content: z .string() .describe( "Text content to store for later retrieval by this conversation's agents.", ), }) .strict(); const ReadHandoffInput = z .object({ path: HandoffPathInput.describe( "Relative path inside the conversation handoff store.", ), }) .strict(); const GetSkillInput = z .object({ name: NonEmptyText.describe("Skill name from list_skills."), }) .strict(); // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- const plugin: AgentPlugin = { name: "portable-subagents", manifest: { capabilities: ["tools"] }, setup(api, ctx) { const logger = ctx.logger; logger?.log("portable-subagents plugin setup", { sessionId: ctx.session?.sessionId, defaultPreset: DEFAULT_AGENT_PRESET, backendMode: DEFAULT_BACKEND_MODE, workspaceRoot: ctx.workspaceInfo?.rootPath, }); // -- start_subagent: Start a new subagent session -- api.registerTool( toRegisteredTool( createTool({ name: "start_subagent", description: `Start a background subagent run and return its session ID immediately. Prefer a preset from list_agent_presets; when omitted, this tool uses the bundled "${DEFAULT_AGENT_PRESET}" preset automatically. Use get_subagent to poll, or keep notifyParent enabled to have the result pushed back into the parent session.`, inputSchema: StartSubagentInput, timeoutMs: 60_000, retryable: false, async execute(input, ctx) { const mgr = await getSessionManager(); const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd()); const defs = readAgentDefinitions(baseCwd); const presetName = input.preset ?? DEFAULT_AGENT_PRESET; const def = defs.find((d) => d.name === presetName); if (presetName && !def && !input.instructions?.trim()) { throw new Error(`Unknown agent preset: ${presetName}`); } const cwd = resolve( baseCwd, input.workingDirectory ?? def?.cwd ?? ".", ); const providerId = input.providerId ?? def?.providerId ?? DEFAULT_PROVIDER_ID; const modelId = input.modelId ?? def?.modelId ?? DEFAULT_MODEL_ID; const prompt = [ def?.systemPrompt?.trim(), input.instructions?.trim(), ] .filter(Boolean) .join("\n\n"); if (!prompt) { throw new Error( `Subagent "${input.label}" needs instructions. Provide "instructions" or use an available preset such as "${DEFAULT_AGENT_PRESET}".`, ); } const { sessionId } = await mgr.start({ config: { providerId, modelId, cwd, workspaceRoot: cwd, enableTools: true, enableSpawnAgent: false, enableAgentTeams: false, pluginPaths: [], systemPrompt: prompt, maxIterations: input.maxIterations ?? def?.maxIterations, }, interactive: false, }); const subagent: RunningSubagent = { sessionId, parentSessionId: parentSessionId(ctx), name: input.label, task: input.task, agent: input.preset, startedAt: Date.now(), status: "running", }; subagents.set(sessionId, subagent); logger?.log("Started subagent", { sessionId, toolName: "start_subagent", label: input.label, preset: def?.name ?? input.preset, providerId, modelId, }); void runSubagentTurn( subagent, input.task, input.notifyParent !== false, ); return { status: "started", sessionId, label: subagent.name, preset: def?.name ?? input.preset, task: subagent.task, }; }, }), ), ); // -- list_agent_presets: Show available agent definitions -- api.registerTool( toRegisteredTool( createTool({ name: "list_agent_presets", description: "List the available subagent presets, including bundled, global, and project-level definitions.", inputSchema: z.object({}).strict(), async execute(_input, _ctx) { const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd()); const agents = readAgentDefinitions(baseCwd).map((a) => ({ name: a.name, description: a.description, providerId: a.providerId ?? DEFAULT_PROVIDER_ID, modelId: a.modelId ?? DEFAULT_MODEL_ID, source: a.source, })); return { agents, text: agents.length ? agents .map( (a) => `- ${a.name} [${a.source}] (${a.providerId}/${a.modelId})${a.description ? `: ${a.description}` : ""}`, ) .join("\n") : "No agent definitions found.", }; }, }), ), ); // -- message_subagent: Send follow-up to an existing session -- api.registerTool( toRegisteredTool( createTool({ name: "message_subagent", description: "Send a follow-up message to an existing subagent session and return immediately.", inputSchema: MessageSubagentInput, timeoutMs: 60_000, retryable: false, async execute(input, ctx) { const mgr = await getSessionManager(); const record = await mgr.get(input.sessionId); if (!record) { throw new Error(`Unknown session: ${input.sessionId}`); } const subagent: RunningSubagent = subagents.get( input.sessionId, ) ?? { sessionId: input.sessionId, parentSessionId: parentSessionId(ctx), name: input.sessionId, task: input.prompt, startedAt: Date.now(), status: "running", }; subagent.parentSessionId = parentSessionId(ctx); subagent.task = input.prompt; subagent.status = "running"; subagent.error = undefined; subagents.set(subagent.sessionId, subagent); logger?.log("Queued subagent follow-up", { sessionId: subagent.sessionId, toolName: "message_subagent", label: subagent.name, }); void runSubagentTurn( subagent, input.prompt, input.notifyParent !== false, ); return { status: "started", sessionId: subagent.sessionId, label: subagent.name, task: subagent.task, }; }, }), ), ); // -- get_subagent: Check subagent result -- api.registerTool( toRegisteredTool( createTool({ name: "get_subagent", description: "Get the latest status, output, and error details for a subagent session.", inputSchema: GetSubagentInput, async execute(input, _ctx) { const subagent = subagents.get(input.sessionId); if (!subagent) { return { status: "unknown", sessionId: input.sessionId, text: `No tracked session: ${input.sessionId}`, }; } return { status: subagent.status, sessionId: subagent.sessionId, label: subagent.name, task: subagent.task, finishReason: subagent.finishReason, error: subagent.error, text: subagent.resultText ?? (subagent.status === "running" ? "Still running." : ""), }; }, }), ), ); // -- save_handoff: Persist a conversation-scoped handoff file -- api.registerTool( toRegisteredTool( createTool({ name: "save_handoff", description: "Save text into the conversation handoff store so other agents in this conversation can read it later.", inputSchema: SaveHandoffInput, async execute(input, ctx) { const filePath = resolveHandoffPath(ctx, input.path); mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, input.content, "utf8"); return { path: filePath, handoffPath: input.path }; }, }), ), ); // -- read_handoff: Read a conversation-scoped handoff file -- api.registerTool( toRegisteredTool( createTool({ name: "read_handoff", description: "Read text from the conversation handoff store.", inputSchema: ReadHandoffInput, async execute(input, ctx) { const filePath = resolveHandoffPath(ctx, input.path); if (!existsSync(filePath)) { throw new Error(`Handoff not found: ${input.path}`); } return { path: filePath, handoffPath: input.path, content: readFileSync(filePath, "utf8"), }; }, }), ), ); // -- list_skills: Show available skill definitions -- api.registerTool( toRegisteredTool( createTool({ name: "list_skills", description: "List the available skill definitions from bundled, global, and project-level directories.", inputSchema: z.object({}).strict(), async execute(_input, _ctx) { const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd()); const skills = readSkillDefinitions(baseCwd); return { skills: skills.map((s) => ({ name: s.name, description: s.description, source: s.source, })), text: skills.length ? skills .map( (s) => `- ${s.name} [${s.source}]${s.description ? `: ${s.description}` : ""}`, ) .join("\n") : "No skill definitions found.", }; }, }), ), ); // -- get_skill: Load a skill's instructions -- api.registerTool( toRegisteredTool( createTool({ name: "get_skill", description: "Get a skill by name, including the instructions that should be followed for that specialization.", inputSchema: GetSkillInput, async execute(input, _ctx) { const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd()); const skills = readSkillDefinitions(baseCwd); const skill = skills.find((s) => s.name === input.name); if (!skill) { const available = skills.map((s) => s.name).join(", "); throw new Error( `Unknown skill: "${input.name}". Available: ${available || "none"}`, ); } return { name: skill.name, description: skill.description, source: skill.source, instructions: skill.content, }; }, }), ), ); }, }; export { plugin }; export default plugin;