Files
paperclip/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts

1042 lines
35 KiB
TypeScript

import { Buffer } from "node:buffer";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import {
PLUGIN_STATE_SCOPE_KINDS,
definePlugin,
runWorker,
type PaperclipPlugin,
type PluginContext,
type PluginEntityQuery,
type PluginEvent,
type PluginHealthDiagnostics,
type PluginJobContext,
type PluginLauncherRegistration,
type PluginWebhookInput,
type PluginWorkspace,
type PluginStateScopeKind,
type ScopeKey,
type ToolResult,
type ToolRunContext,
} from "@paperclipai/plugin-sdk";
import type { Goal, Issue } from "@paperclipai/shared";
import {
DEFAULT_CONFIG,
JOB_KEYS,
PLUGIN_ID,
RUNTIME_LAUNCHER,
SAFE_COMMANDS,
STREAM_CHANNELS,
TOOL_NAMES,
WEBHOOK_KEYS,
} from "./constants.js";
type KitchenSinkConfig = {
showSidebarEntry?: boolean;
showSidebarPanel?: boolean;
showProjectSidebarItem?: boolean;
showCommentAnnotation?: boolean;
showCommentContextMenuItem?: boolean;
enableWorkspaceDemos?: boolean;
enableProcessDemos?: boolean;
secretRefExample?: string;
httpDemoUrl?: string;
allowedCommands?: string[];
workspaceScratchFile?: string;
};
type DemoRecord = {
id: string;
level: "info" | "warning" | "error";
source: string;
message: string;
createdAt: string;
data?: unknown;
};
type ProcessResult = {
commandKey: string;
cwd: string;
code: number | null;
stdout: string;
stderr: string;
startedAt: string;
finishedAt: string;
};
const recentRecords: DemoRecord[] = [];
const runtimeLaunchers = new Map<string, PluginLauncherRegistration>();
let currentContext: PluginContext | null = null;
let lastProcessResult: ProcessResult | null = null;
function isScopeKind(value: unknown): value is PluginStateScopeKind {
return typeof value === "string" && PLUGIN_STATE_SCOPE_KINDS.includes(value as PluginStateScopeKind);
}
function summarizeError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function pushRecord(record: Omit<DemoRecord, "id" | "createdAt">): DemoRecord {
const next: DemoRecord = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...record,
};
recentRecords.unshift(next);
if (recentRecords.length > 50) recentRecords.length = 50;
return next;
}
async function getConfig(ctx: PluginContext): Promise<KitchenSinkConfig> {
const config = await ctx.config.get();
return {
...DEFAULT_CONFIG,
...(config as KitchenSinkConfig),
};
}
async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise<void> {
await ctx.state.set({ scopeKind: "instance", stateKey }, value);
}
async function readInstanceState<T = unknown>(ctx: PluginContext, stateKey: string): Promise<T | null> {
return await ctx.state.get({ scopeKind: "instance", stateKey }) as T | null;
}
async function resolveWorkspace(
ctx: PluginContext,
companyId: string,
projectId: string,
workspaceId?: string,
): Promise<PluginWorkspace> {
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
if (workspaces.length === 0) {
throw new Error("No workspaces configured for this project");
}
if (!workspaceId) return workspaces[0]!;
const workspace = workspaces.find((entry) => entry.id === workspaceId);
if (!workspace) {
throw new Error("Workspace not found");
}
return workspace;
}
function ensureInsideWorkspace(workspacePath: string, relativePath: string): string {
const root = path.resolve(workspacePath);
const resolved = path.resolve(root, relativePath);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Requested path escapes the selected workspace");
}
return resolved;
}
function parseJsonish(value: string): unknown {
const trimmed = value.trim();
if (trimmed.length === 0) return "";
try {
return JSON.parse(trimmed) as unknown;
} catch {
return value;
}
}
function parseScopeKey(params: Record<string, unknown>): ScopeKey {
const scopeKind = isScopeKind(params.scopeKind) ? params.scopeKind : "instance";
const scopeId = typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined;
const namespace = typeof params.namespace === "string" && params.namespace.length > 0 ? params.namespace : undefined;
const stateKey = typeof params.stateKey === "string" && params.stateKey.length > 0
? params.stateKey
: "demo";
return { scopeKind, scopeId, namespace, stateKey };
}
async function runCuratedCommand(
ctx: PluginContext,
config: KitchenSinkConfig,
companyId: string,
projectId: string,
workspaceId: string | undefined,
commandKey: string,
): Promise<ProcessResult> {
if (!config.enableProcessDemos) {
throw new Error("Process demos are disabled in plugin settings");
}
const allowedCommands = new Set(config.allowedCommands ?? DEFAULT_CONFIG.allowedCommands);
if (!allowedCommands.has(commandKey)) {
throw new Error(`Command "${commandKey}" is not allowed by plugin settings`);
}
const definition = SAFE_COMMANDS.find((entry) => entry.key === commandKey);
if (!definition) {
throw new Error(`Unknown curated command "${commandKey}"`);
}
const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId);
const cwd = workspace.path;
const startedAt = new Date().toISOString();
const child = spawn(definition.command, definition.args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
const code = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
const result: ProcessResult = {
commandKey,
cwd,
code,
stdout: stdout.trim(),
stderr: stderr.trim(),
startedAt,
finishedAt: new Date().toISOString(),
};
lastProcessResult = result;
pushRecord({
level: code === 0 ? "info" : "warning",
source: "process",
message: `Ran curated command "${commandKey}"`,
data: { code, cwd },
});
await ctx.metrics.write("process.run", 1, { command: commandKey, exit_code: String(code ?? -1) });
return result;
}
function getCurrentCompanyId(params: Record<string, unknown>): string {
const companyId = typeof params.companyId === "string" ? params.companyId : "";
if (!companyId) {
throw new Error("companyId is required");
}
return companyId;
}
function getListLimit(params: Record<string, unknown>, fallback = 50): number {
const value = typeof params.limit === "number" ? params.limit : Number(params.limit ?? fallback);
if (!Number.isFinite(value)) return fallback;
return Math.max(1, Math.min(200, Math.floor(value)));
}
async function listIssuesForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Issue[]> {
return await ctx.issues.list({ companyId, limit, offset: 0 });
}
async function listGoalsForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Goal[]> {
return await ctx.goals.list({ companyId, limit, offset: 0 });
}
function recentRecordsSnapshot(): DemoRecord[] {
return recentRecords.slice(0, 20);
}
function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] {
return [...runtimeLaunchers.values()];
}
async function registerDataHandlers(ctx: PluginContext): Promise<void> {
ctx.data.register("plugin-config", async () => {
return await getConfig(ctx);
});
ctx.data.register("overview", async (params) => {
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const config = await getConfig(ctx);
const companies = await ctx.companies.list({ limit: 200, offset: 0 });
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : [];
const goals = companyId ? await listGoalsForCompany(ctx, companyId, 200) : [];
const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
const lastJob = await readInstanceState(ctx, "last-job-run");
const lastWebhook = await readInstanceState(ctx, "last-webhook");
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
return {
pluginId: PLUGIN_ID,
version: ctx.manifest.version,
capabilities: ctx.manifest.capabilities,
config,
runtimeLaunchers: runtimeLaunchersSnapshot(),
recentRecords: recentRecordsSnapshot(),
counts: {
companies: companies.length,
projects: projects.length,
issues: issues.length,
goals: goals.length,
agents: agents.length,
entities: entityRecords.length,
},
lastJob,
lastWebhook,
lastProcessResult,
streamChannels: STREAM_CHANNELS,
safeCommands: SAFE_COMMANDS,
manifest: {
jobs: ctx.manifest.jobs ?? [],
webhooks: ctx.manifest.webhooks ?? [],
tools: ctx.manifest.tools ?? [],
},
};
});
ctx.data.register("companies", async (params) => {
return await ctx.companies.list({ limit: getListLimit(params), offset: 0 });
});
ctx.data.register("projects", async (params) => {
const companyId = getCurrentCompanyId(params);
return await ctx.projects.list({ companyId, limit: getListLimit(params), offset: 0 });
});
ctx.data.register("issues", async (params) => {
const companyId = getCurrentCompanyId(params);
return await listIssuesForCompany(ctx, companyId, getListLimit(params));
});
ctx.data.register("goals", async (params) => {
const companyId = getCurrentCompanyId(params);
return await listGoalsForCompany(ctx, companyId, getListLimit(params));
});
ctx.data.register("agents", async (params) => {
const companyId = getCurrentCompanyId(params);
return await ctx.agents.list({ companyId, limit: getListLimit(params), offset: 0 });
});
ctx.data.register("workspaces", async (params) => {
const companyId = getCurrentCompanyId(params);
const projectId = typeof params.projectId === "string" ? params.projectId : "";
if (!projectId) return [];
return await ctx.projects.listWorkspaces(projectId, companyId);
});
ctx.data.register("state-value", async (params) => {
const input = parseScopeKey(params);
const value = await ctx.state.get(input);
return {
scope: input,
value,
};
});
ctx.data.register("entities", async (params) => {
const query: PluginEntityQuery = {
entityType: typeof params.entityType === "string" && params.entityType.length > 0 ? params.entityType : undefined,
scopeKind: isScopeKind(params.scopeKind) ? params.scopeKind : undefined,
scopeId: typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined,
limit: typeof params.limit === "number" ? params.limit : 25,
offset: 0,
};
return await ctx.entities.list(query);
});
ctx.data.register("comment-context", async (params) => {
const companyId = getCurrentCompanyId(params);
const issueId = typeof params.issueId === "string" ? params.issueId : "";
const commentId = typeof params.commentId === "string" ? params.commentId : "";
if (!issueId || !commentId) return null;
const comments = await ctx.issues.listComments(issueId, companyId);
const comment = comments.find((entry) => entry.id === commentId) ?? null;
if (!comment) return null;
return {
commentId: comment.id,
issueId,
preview: comment.body.slice(0, 160),
length: comment.body.length,
copiedCount: (await ctx.entities.list({
entityType: "copied-comment",
scopeKind: "issue",
scopeId: issueId,
limit: 100,
offset: 0,
})).filter((entry) => entry.externalId === commentId).length,
};
});
ctx.data.register("entity-context", async (params) => {
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const entityId = typeof params.entityId === "string" ? params.entityId : "";
const entityType = typeof params.entityType === "string" ? params.entityType : "";
if (!companyId || !entityId || !entityType) return null;
if (entityType === "project") {
return await ctx.projects.get(entityId, companyId);
}
if (entityType === "issue") {
return await ctx.issues.get(entityId, companyId);
}
if (entityType === "goal") {
return await ctx.goals.get(entityId, companyId);
}
if (entityType === "agent") {
return await ctx.agents.get(entityId, companyId);
}
return { entityId, entityType, companyId };
});
}
async function registerActionHandlers(ctx: PluginContext): Promise<void> {
ctx.actions.register("emit-demo-event", async (params) => {
const companyId = getCurrentCompanyId(params);
const message = typeof params.message === "string" && params.message.trim().length > 0
? params.message.trim()
: "Kitchen Sink demo event";
await ctx.events.emit("demo-event", companyId, {
message,
source: "kitchen-sink",
emittedAt: new Date().toISOString(),
});
pushRecord({
level: "info",
source: "events.emit",
message,
data: { companyId },
});
await ctx.metrics.write("demo.events.emitted", 1, { source: "manual" });
return { ok: true, message };
});
ctx.actions.register("write-scoped-state", async (params) => {
const input = parseScopeKey(params);
const valueInput = typeof params.value === "string" ? params.value : JSON.stringify(params.value ?? "");
const value = parseJsonish(valueInput);
await ctx.state.set(input, value);
pushRecord({
level: "info",
source: "state",
message: `Wrote state key ${input.stateKey}`,
data: input,
});
await ctx.metrics.write("demo.state.write", 1, { scope: input.scopeKind });
return { ok: true, scope: input, value };
});
ctx.actions.register("delete-scoped-state", async (params) => {
const input = parseScopeKey(params);
await ctx.state.delete(input);
pushRecord({
level: "warning",
source: "state",
message: `Deleted state key ${input.stateKey}`,
data: input,
});
return { ok: true, scope: input };
});
ctx.actions.register("upsert-entity", async (params) => {
const title = typeof params.title === "string" && params.title.length > 0 ? params.title : "Kitchen Sink Entity";
const entityType = typeof params.entityType === "string" && params.entityType.length > 0
? params.entityType
: "demo-record";
const scopeKind = isScopeKind(params.scopeKind)
? params.scopeKind
: "instance";
const scopeId = typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined;
const status = typeof params.status === "string" && params.status.length > 0 ? params.status : "active";
const data = typeof params.data === "string" ? parseJsonish(params.data) : params.data;
const record = await ctx.entities.upsert({
entityType,
scopeKind,
scopeId,
externalId: typeof params.externalId === "string" && params.externalId.length > 0 ? params.externalId : randomUUID(),
title,
status,
data: typeof data === "object" && data !== null ? data as Record<string, unknown> : { value: data },
});
pushRecord({
level: "info",
source: "entities",
message: `Upserted entity ${record.entityType}`,
data: { id: record.id, scopeKind: record.scopeKind },
});
return record;
});
ctx.actions.register("create-issue", async (params) => {
const companyId = getCurrentCompanyId(params);
const title = typeof params.title === "string" && params.title.trim().length > 0
? params.title.trim()
: "Kitchen Sink demo issue";
const description = typeof params.description === "string" ? params.description : undefined;
const projectId = typeof params.projectId === "string" && params.projectId.length > 0 ? params.projectId : undefined;
const issue = await ctx.issues.create({ companyId, projectId, title, description });
pushRecord({
level: "info",
source: "issues.create",
message: `Created issue ${issue.title}`,
data: { issueId: issue.id },
});
await ctx.activity.log({
companyId,
entityType: "issue",
entityId: issue.id,
message: `Kitchen Sink created issue "${issue.title}"`,
metadata: { plugin: PLUGIN_ID },
});
return issue;
});
ctx.actions.register("advance-issue-status", async (params) => {
const companyId = getCurrentCompanyId(params);
const issueId = typeof params.issueId === "string" ? params.issueId : "";
const status = typeof params.status === "string" ? params.status : "";
if (!issueId || !status) {
throw new Error("issueId and status are required");
}
const issue = await ctx.issues.update(issueId, { status: status as Issue["status"] }, companyId);
pushRecord({
level: "info",
source: "issues.update",
message: `Updated issue ${issue.id} to ${issue.status}`,
});
return issue;
});
ctx.actions.register("create-goal", async (params) => {
const companyId = getCurrentCompanyId(params);
const title = typeof params.title === "string" && params.title.trim().length > 0
? params.title.trim()
: "Kitchen Sink demo goal";
const description = typeof params.description === "string" ? params.description : undefined;
const goal = await ctx.goals.create({ companyId, title, description, level: "team", status: "planned" });
pushRecord({
level: "info",
source: "goals.create",
message: `Created goal ${goal.title}`,
data: { goalId: goal.id },
});
return goal;
});
ctx.actions.register("advance-goal-status", async (params) => {
const companyId = getCurrentCompanyId(params);
const goalId = typeof params.goalId === "string" ? params.goalId : "";
const status = typeof params.status === "string" ? params.status : "";
if (!goalId || !status) {
throw new Error("goalId and status are required");
}
const goal = await ctx.goals.update(goalId, { status: status as Goal["status"] }, companyId);
pushRecord({
level: "info",
source: "goals.update",
message: `Updated goal ${goal.id} to ${goal.status}`,
});
return goal;
});
ctx.actions.register("write-activity", async (params) => {
const companyId = getCurrentCompanyId(params);
const entityType = typeof params.entityType === "string" ? params.entityType : undefined;
const entityId = typeof params.entityId === "string" ? params.entityId : undefined;
const message = typeof params.message === "string" && params.message.length > 0
? params.message
: "Kitchen Sink wrote an activity entry";
await ctx.activity.log({
companyId,
entityType,
entityId,
message,
metadata: { plugin: PLUGIN_ID },
});
pushRecord({
level: "info",
source: "activity",
message,
data: { entityType, entityId },
});
return { ok: true };
});
ctx.actions.register("write-metric", async (params) => {
const value = typeof params.value === "number" ? params.value : Number(params.value ?? 1);
const name = typeof params.name === "string" && params.name.length > 0 ? params.name : "manual";
await ctx.metrics.write(`demo.${name}`, Number.isFinite(value) ? value : 1, { source: "manual" });
pushRecord({
level: "info",
source: "metrics",
message: `Wrote metric demo.${name}`,
data: { value },
});
return { ok: true, value };
});
ctx.actions.register("http-fetch", async (params) => {
const config = await getConfig(ctx);
const url = typeof params.url === "string" && params.url.length > 0
? params.url
: config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl;
const started = Date.now();
const response = await ctx.http.fetch(url, { method: "GET" });
const body = await response.text();
const result = {
ok: response.ok,
status: response.status,
url,
durationMs: Date.now() - started,
body: body.slice(0, 2000),
};
pushRecord({
level: response.ok ? "info" : "warning",
source: "http",
message: `Fetched ${url}`,
data: { status: response.status },
});
return result;
});
ctx.actions.register("resolve-secret", async (params) => {
const config = await getConfig(ctx);
const secretRef = typeof params.secretRef === "string" && params.secretRef.length > 0
? params.secretRef
: config.secretRefExample || "";
if (!secretRef) {
throw new Error("No secret reference configured");
}
const resolved = await ctx.secrets.resolve(secretRef);
pushRecord({
level: "info",
source: "secrets",
message: `Resolved secret reference ${secretRef}`,
});
return {
secretRef,
resolvedLength: resolved.length,
preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "",
};
});
ctx.actions.register("run-process", async (params) => {
const config = await getConfig(ctx);
const companyId = getCurrentCompanyId(params);
const projectId = typeof params.projectId === "string" ? params.projectId : "";
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd";
if (!projectId) throw new Error("projectId is required");
return await runCuratedCommand(ctx, config, companyId, projectId, workspaceId, commandKey);
});
ctx.actions.register("read-workspace-file", async (params) => {
const config = await getConfig(ctx);
if (!config.enableWorkspaceDemos) {
throw new Error("Workspace demos are disabled in plugin settings");
}
const companyId = getCurrentCompanyId(params);
const projectId = typeof params.projectId === "string" ? params.projectId : "";
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
? params.relativePath
: config.workspaceScratchFile || DEFAULT_CONFIG.workspaceScratchFile;
if (!projectId) throw new Error("projectId is required");
const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId);
const fullPath = ensureInsideWorkspace(workspace.path, relativePath);
const content = await fs.readFile(fullPath, "utf8");
return {
workspaceId: workspace.id,
relativePath,
content,
};
});
ctx.actions.register("write-workspace-scratch", async (params) => {
const config = await getConfig(ctx);
if (!config.enableWorkspaceDemos) {
throw new Error("Workspace demos are disabled in plugin settings");
}
const companyId = getCurrentCompanyId(params);
const projectId = typeof params.projectId === "string" ? params.projectId : "";
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
? params.relativePath
: config.workspaceScratchFile || DEFAULT_CONFIG.workspaceScratchFile;
const content = typeof params.content === "string" ? params.content : "Kitchen Sink workspace demo";
if (!projectId) throw new Error("projectId is required");
const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId);
const fullPath = ensureInsideWorkspace(workspace.path, relativePath);
await fs.writeFile(fullPath, content, "utf8");
pushRecord({
level: "info",
source: "workspace",
message: `Wrote scratch file ${relativePath}`,
data: { workspaceId: workspace.id },
});
return {
workspaceId: workspace.id,
relativePath,
bytes: Buffer.byteLength(content, "utf8"),
};
});
ctx.actions.register("start-progress-stream", async (params) => {
const companyId = getCurrentCompanyId(params);
const steps = typeof params.steps === "number" ? params.steps : 5;
void (async () => {
ctx.streams.open(STREAM_CHANNELS.progress, companyId);
try {
for (let index = 1; index <= steps; index += 1) {
ctx.streams.emit(STREAM_CHANNELS.progress, {
step: index,
total: steps,
message: `Progress step ${index}/${steps}`,
});
await new Promise((resolve) => setTimeout(resolve, 350));
}
} finally {
ctx.streams.close(STREAM_CHANNELS.progress);
}
})();
return { ok: true, channel: STREAM_CHANNELS.progress };
});
ctx.actions.register("invoke-agent", async (params) => {
const companyId = getCurrentCompanyId(params);
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const prompt = typeof params.prompt === "string" && params.prompt.length > 0
? params.prompt
: "Kitchen Sink test invocation";
if (!agentId) throw new Error("agentId is required");
const result = await ctx.agents.invoke(agentId, companyId, { prompt, reason: "Kitchen Sink plugin demo" });
pushRecord({
level: "info",
source: "agents.invoke",
message: `Invoked agent ${agentId}`,
data: result,
});
return result;
});
ctx.actions.register("pause-agent", async (params) => {
const companyId = getCurrentCompanyId(params);
const agentId = typeof params.agentId === "string" ? params.agentId : "";
if (!agentId) throw new Error("agentId is required");
return await ctx.agents.pause(agentId, companyId);
});
ctx.actions.register("resume-agent", async (params) => {
const companyId = getCurrentCompanyId(params);
const agentId = typeof params.agentId === "string" ? params.agentId : "";
if (!agentId) throw new Error("agentId is required");
return await ctx.agents.resume(agentId, companyId);
});
ctx.actions.register("ask-agent", async (params) => {
const companyId = getCurrentCompanyId(params);
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const prompt = typeof params.prompt === "string" && params.prompt.length > 0
? params.prompt
: "Say hello from the Kitchen Sink plugin.";
if (!agentId) throw new Error("agentId is required");
ctx.streams.open(STREAM_CHANNELS.agentChat, companyId);
const session = await ctx.agents.sessions.create(agentId, companyId, {
reason: "Kitchen Sink plugin chat demo",
});
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
prompt,
reason: "Kitchen Sink demo",
onEvent: (event) => {
ctx.streams.emit(STREAM_CHANNELS.agentChat, {
eventType: event.eventType,
stream: event.stream,
message: event.message,
payload: event.payload,
});
if (event.eventType === "done" || event.eventType === "error") {
ctx.streams.close(STREAM_CHANNELS.agentChat);
}
},
});
pushRecord({
level: "info",
source: "agent.sessions",
message: `Started agent session ${session.sessionId}`,
data: { agentId, sessionId: session.sessionId },
});
return { channel: STREAM_CHANNELS.agentChat, sessionId: session.sessionId };
});
ctx.actions.register("copy-comment-context", async (params) => {
const companyId = getCurrentCompanyId(params);
const issueId = typeof params.issueId === "string" ? params.issueId : "";
const commentId = typeof params.commentId === "string" ? params.commentId : "";
if (!issueId || !commentId) {
throw new Error("issueId and commentId are required");
}
const comments = await ctx.issues.listComments(issueId, companyId);
const comment = comments.find((entry) => entry.id === commentId);
if (!comment) {
throw new Error("Comment not found");
}
const record = await ctx.entities.upsert({
entityType: "copied-comment",
scopeKind: "issue",
scopeId: issueId,
externalId: comment.id,
title: `Copied comment ${comment.id.slice(0, 8)}`,
status: "captured",
data: {
commentId: comment.id,
issueId,
body: comment.body,
},
});
pushRecord({
level: "info",
source: "comments",
message: `Copied comment ${comment.id} into plugin entities`,
data: { recordId: record.id },
});
return record;
});
}
async function registerToolHandlers(ctx: PluginContext): Promise<void> {
ctx.tools.register(
TOOL_NAMES.echo,
{
displayName: "Kitchen Sink Echo",
description: "Echoes the provided message back to the caller.",
parametersSchema: {
type: "object",
properties: {
message: { type: "string" },
},
required: ["message"],
},
},
async (params, runCtx): Promise<ToolResult> => {
const payload = params as { message?: string };
return {
content: payload.message ?? "No message provided",
data: {
runCtx,
message: payload.message ?? "",
},
};
},
);
ctx.tools.register(
TOOL_NAMES.companySummary,
{
displayName: "Kitchen Sink Company Summary",
description: "Summarizes current company counts from the Paperclip APIs.",
parametersSchema: { type: "object", properties: {} },
},
async (_params, runCtx): Promise<ToolResult> => {
const projects = await ctx.projects.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
const issues = await ctx.issues.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
const goals = await ctx.goals.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
const agents = await ctx.agents.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
return {
content: `Company has ${projects.length} projects, ${issues.length} issues, ${goals.length} goals, and ${agents.length} agents.`,
data: {
companyId: runCtx.companyId,
projects: projects.length,
issues: issues.length,
goals: goals.length,
agents: agents.length,
},
};
},
);
ctx.tools.register(
TOOL_NAMES.createIssue,
{
displayName: "Kitchen Sink Create Issue",
description: "Creates an issue in the current run context.",
parametersSchema: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
},
required: ["title"],
},
},
async (params, runCtx): Promise<ToolResult> => {
const payload = params as { title?: string; description?: string };
if (!payload.title) {
return { error: "title is required" };
}
const issue = await ctx.issues.create({
companyId: runCtx.companyId,
projectId: runCtx.projectId,
title: payload.title,
description: payload.description,
});
return {
content: `Created issue ${issue.title}`,
data: issue,
};
},
);
}
async function registerEventHandlers(ctx: PluginContext): Promise<void> {
ctx.events.on("issue.created", async (event: PluginEvent) => {
pushRecord({
level: "info",
source: "events.subscribe",
message: "Observed issue.created",
data: event,
});
});
ctx.events.on("issue.updated", async (event: PluginEvent) => {
pushRecord({
level: "info",
source: "events.subscribe",
message: "Observed issue.updated",
data: event,
});
});
ctx.events.on(`plugin.${PLUGIN_ID}.demo-event`, async (event: PluginEvent) => {
pushRecord({
level: "info",
source: "plugin-event",
message: "Observed plugin demo event",
data: event,
});
});
}
async function registerJobs(ctx: PluginContext): Promise<void> {
ctx.jobs.register(JOB_KEYS.heartbeat, async (job: PluginJobContext) => {
const payload = {
jobKey: job.jobKey,
runId: job.runId,
trigger: job.trigger,
scheduledAt: job.scheduledAt,
completedAt: new Date().toISOString(),
};
await writeInstanceState(ctx, "last-job-run", payload);
pushRecord({
level: "info",
source: "jobs",
message: "Kitchen Sink demo job ran",
data: payload,
});
await ctx.metrics.write("jobs.demo_heartbeat", 1, { trigger: job.trigger });
});
}
const plugin: PaperclipPlugin = definePlugin({
async setup(ctx) {
currentContext = ctx;
runtimeLaunchers.set(RUNTIME_LAUNCHER.id, RUNTIME_LAUNCHER);
ctx.launchers.register(RUNTIME_LAUNCHER);
pushRecord({
level: "info",
source: "setup",
message: "Kitchen Sink plugin setup complete",
data: { pluginId: PLUGIN_ID },
});
await registerEventHandlers(ctx);
await registerJobs(ctx);
await registerDataHandlers(ctx);
await registerActionHandlers(ctx);
await registerToolHandlers(ctx);
},
async onHealth(): Promise<PluginHealthDiagnostics> {
const ctx = currentContext;
const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG;
return {
status: "ok",
message: "Kitchen Sink plugin ready",
details: {
recordsTracked: recentRecords.length,
runtimeLaunchers: runtimeLaunchers.size,
processDemosEnabled: config.enableProcessDemos === true,
workspaceDemosEnabled: config.enableWorkspaceDemos !== false,
},
};
},
async onConfigChanged(newConfig) {
pushRecord({
level: "info",
source: "config",
message: "Kitchen Sink config changed",
data: newConfig,
});
},
async onValidateConfig(config) {
const errors: string[] = [];
const warnings: string[] = [];
const typed = config as KitchenSinkConfig;
if (typed.httpDemoUrl && typeof typed.httpDemoUrl !== "string") {
errors.push("httpDemoUrl must be a string");
}
if (typed.allowedCommands && !Array.isArray(typed.allowedCommands)) {
errors.push("allowedCommands must be an array");
}
if (Array.isArray(typed.allowedCommands)) {
const allowed = new Set<string>(SAFE_COMMANDS.map((command) => command.key));
const invalid = typed.allowedCommands.filter((value) => typeof value !== "string" || !allowed.has(value));
if (invalid.length > 0) {
errors.push(`allowedCommands contains unsupported values: ${invalid.join(", ")}`);
}
}
if (typed.enableProcessDemos) {
warnings.push("Process demos run local child processes and are intended only for trusted development environments.");
}
return {
ok: errors.length === 0,
warnings,
errors,
};
},
async onWebhook(input: PluginWebhookInput) {
const payload = {
endpointKey: input.endpointKey,
requestId: input.requestId,
rawBody: input.rawBody,
parsedBody: input.parsedBody,
receivedAt: new Date().toISOString(),
};
const ctx = currentContext;
if (ctx) {
await writeInstanceState(ctx, "last-webhook", payload);
}
pushRecord({
level: "info",
source: "webhook",
message: `Received webhook ${input.endpointKey}`,
data: payload,
});
if (input.endpointKey !== WEBHOOK_KEYS.demo) {
throw new Error(`Unsupported webhook endpoint "${input.endpointKey}"`);
}
},
async onShutdown() {
pushRecord({
level: "warning",
source: "shutdown",
message: "Kitchen Sink plugin shutting down",
});
},
});
export default plugin;
runWorker(plugin, import.meta.url);