Files
2026-07-13 12:52:40 +08:00

864 lines
25 KiB
TypeScript

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<string, RunningSubagent>();
let sessionManagerPromise: Promise<SessionManager> | undefined;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Cast a fully-typed tool to the `AgentTool<unknown, unknown>` 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<I, O>(
tool: AgentTool<I, O>,
): AgentTool<unknown, unknown> {
return tool as AgentTool<unknown, unknown>;
}
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<string, unknown>;
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<string, unknown>)
: {},
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<string, unknown>;
body: string;
source: typeof source;
}> {
if (!existsSync(dirPath)) return [];
const results: Array<{
name: string;
data: Record<string, unknown>;
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<string, AgentDefinition>();
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<string, SkillDefinition>();
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<SessionManager> {
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<void> {
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;