new plugin

This commit is contained in:
2026-03-20 22:53:01 -04:00
parent ecd78f7528
commit 0efd661184
38 changed files with 1570 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
export const PLUGIN_ID = "paperclip.plugin-agent-inbox-config";
export const PLUGIN_VERSION = "0.1.0";
// Slot IDs
export const SIDEBAR_SLOT_ID = "agent-inbox-config-sidebar";
export const PAGE_SLOT_ID = "agent-inbox-config-page";
export const AGENT_TAB_SLOT_ID = "agent-inbox-tab";
// Export names
export const EXPORT_NAMES = {
sidebar: "InboxConfigSidebar",
page: "InboxConfigPage",
agentTab: "AgentInboxSettingsTab",
} as const;
// Default config
export const DEFAULT_INBOX_CONFIG = {
statuses: "todo,in_progress,blocked",
includeBacklog: false,
projectId: null as string | null,
goalId: null as string | null,
labelIds: [] as string[],
query: null as string | null,
};
export type AgentInboxConfig = typeof DEFAULT_INBOX_CONFIG;

View File

@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as worker } from "./worker.js";

View File

@@ -0,0 +1,80 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.plugin-agent-inbox-config";
const PLUGIN_VERSION = "0.1.0";
/**
* Plugin that provides per-agent inbox-lite configuration via UI toggles.
* Allows configuring which issues appear in each agent's inbox without code changes.
*/
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Agent Inbox Configuration",
description: "Configure per-agent inbox-lite filters via UI toggles. Control which issues appear in each agent's inbox based on status, project, goal, labels, and search queries.",
author: "Paperclip",
categories: ["ui", "automation"],
capabilities: [
"agents.read",
"projects.read",
"goals.read",
"issues.read",
"plugin.state.read",
"plugin.state.write",
"http.outbound",
"ui.detailTab.register",
"ui.page.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
instanceConfigSchema: {
type: "object",
title: "Agent Inbox Config Plugin Settings",
description: "Default inbox configuration settings for agents.",
properties: {
defaultStatuses: {
type: "string",
title: "Default Statuses",
description: "Default statuses to include in inbox (comma-separated). Override per-agent via agent settings.",
default: "todo,in_progress,blocked",
},
includeBacklog: {
type: "boolean",
title: "Include Backlog by Default",
description: "Whether backlog issues are included by default.",
default: false,
},
maxIssuesPerAgent: {
type: "integer",
title: "Max Issues Per Agent",
description: "Maximum number of issues to return per agent inbox.",
default: 50,
minimum: 1,
maximum: 500,
},
},
},
ui: {
slots: [
{
type: "page",
id: "agent-inbox-config-page",
displayName: "Agent Inbox Configuration",
exportName: "InboxConfigPage",
routePath: "inbox-config",
},
{
type: "detailTab",
id: "agent-inbox-tab",
displayName: "Inbox Settings",
exportName: "AgentInboxSettingsTab",
entityTypes: ["agent"],
},
],
},
};
export default manifest;

View File

@@ -0,0 +1,490 @@
import { useState, useEffect, type FormEvent } from "react";
import {
useHostContext,
usePluginAction,
usePluginData,
usePluginToast,
type PluginPageProps,
type PluginDetailTabProps,
} from "@paperclipai/plugin-sdk/ui";
import {
DEFAULT_INBOX_CONFIG,
EXPORT_NAMES,
PAGE_SLOT_ID,
AGENT_TAB_SLOT_ID,
type AgentInboxConfig,
} from "../constants.js";
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
type AgentRecord = {
id: string;
name: string;
status: string;
urlKey?: string;
};
// -----------------------------------------------------------------------------
// Styles
// -----------------------------------------------------------------------------
const containerStyle: React.CSSProperties = {
display: "grid",
gap: "16px",
};
const cardStyle: React.CSSProperties = {
border: "1px solid var(--border, #e5e7eb)",
borderRadius: "12px",
padding: "16px",
background: "var(--card, white)",
};
const sectionHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "12px",
};
const buttonStyle: React.CSSProperties = {
appearance: "none",
border: "1px solid var(--border, #e5e7eb)",
borderRadius: "8px",
background: "transparent",
color: "inherit",
padding: "8px 14px",
fontSize: "13px",
cursor: "pointer",
};
const primaryButtonStyle: React.CSSProperties = {
...buttonStyle,
background: "var(--foreground, #1f2937)",
color: "var(--background, white)",
};
const inputStyle: React.CSSProperties = {
width: "100%",
border: "1px solid var(--border, #e5e7eb)",
borderRadius: "8px",
padding: "10px 12px",
background: "transparent",
color: "inherit",
fontSize: "13px",
};
const selectStyle: React.CSSProperties = {
...inputStyle,
};
const checkboxStyle: React.CSSProperties = {
width: "18px",
height: "18px",
accentColor: "var(--foreground, #1f2937)",
};
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "12px",
};
const mutedTextStyle: React.CSSProperties = {
fontSize: "12px",
opacity: 0.7,
lineHeight: 1.5,
};
// -----------------------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------------------
function getStatusOptions(): Array<{ value: string; label: string }> {
return [
{ value: "todo", label: "Todo" },
{ value: "in_progress", label: "In Progress" },
{ value: "blocked", label: "Blocked" },
{ value: "backlog", label: "Backlog" },
{ value: "in_review", label: "In Review" },
{ value: "done", label: "Done" },
{ value: "cancelled", label: "Cancelled" },
];
}
function getStatusLabel(value: string): string {
const option = getStatusOptions().find((o) => o.value === value);
return option?.label || value;
}
// -----------------------------------------------------------------------------
// Page Component - Shows all agents and their inbox configs
// -----------------------------------------------------------------------------
export function InboxConfigPage({ context }: PluginPageProps) {
const [agents, setAgents] = useState<AgentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadAgents() {
if (!context.companyId) return;
setLoading(true);
try {
const response = await fetch(`/api/companies/${context.companyId}/agents`, {
credentials: "include",
});
if (!response.ok) throw new Error(`Failed to load agents: ${response.status}`);
const data = await response.json();
setAgents(Array.isArray(data) ? data : []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
void loadAgents();
}, [context.companyId]);
const agentPath = (agentId: string) => {
const prefix = context.companyPrefix || "";
return `${prefix ? "/${prefix}" : "/"}/agents/${agentId}`;
};
if (!context.companyId) {
return (
<div style={{ padding: "24px", fontSize: "14px", opacity: 0.7 }}>
Select a company to configure agent inboxes.
</div>
);
}
if (loading) {
return <div style={{ padding: "24px", fontSize: "14px" }}>Loading agents</div>;
}
return (
<div style={{ padding: "24px", maxWidth: "1000px", margin: "0 auto" }}>
<h1 style={{ fontSize: "24px", fontWeight: 700, marginBottom: "8px" }}>Agent Inbox Configuration</h1>
<p style={{ ...mutedTextStyle, marginBottom: "24px", fontSize: "14px" }}>
Configure which issues appear in each agent's inbox via the inbox-lite endpoint.
</p>
{error ? (
<div style={{ ...cardStyle, color: "var(--destructive, #dc2626)" }}>{error}</div>
) : (
<div style={{ display: "grid", gap: "16px" }}>
{agents.map((agent) => (
<div key={agent.id} style={cardStyle}>
<div style={sectionHeaderStyle}>
<strong style={{ fontSize: "15px" }}>{agent.name}</strong>
<a
href={`${agentPath(agent.id)}?tab=plugin:${PAGE_SLOT_ID}:${AGENT_TAB_SLOT_ID}`}
style={{ fontSize: "12px", textDecoration: "underline", cursor: "pointer" }}
>
Configure →
</a>
</div>
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<AgentInboxSummary agentId={agent.id} />
</div>
</div>
))}
{agents.length === 0 && (
<div style={{ ...cardStyle, padding: "24px", textAlign: "center", opacity: 0.7 }}>
No agents found in this company.
</div>
)}
</div>
)}
</div>
);
}
// -----------------------------------------------------------------------------
// Agent Inbox Summary Component (used in page view)
// -----------------------------------------------------------------------------
function AgentInboxSummary({ agentId }: { agentId: string }) {
const config = usePluginData<AgentInboxConfig | null>("getAgentInboxConfig", { agentId });
if (config.loading) return <div style={{ fontSize: "12px", opacity: 0.6 }}>Loading…</div>;
if (config.error) return <div style={{ fontSize: "12px", color: "var(--destructive, #dc2626)" }}>Error loading config</div>;
const c = config.data || DEFAULT_INBOX_CONFIG;
return (
<div style={{ display: "grid", gap: "6px", fontSize: "12px" }}>
<div>
<strong>Statuses:</strong> {c.statuses}
</div>
{c.projectId ? (
<div>
<strong>Project:</strong> Filtered
</div>
) : null}
{c.goalId ? (
<div>
<strong>Goal:</strong> Filtered
</div>
) : null}
{c.labelIds && c.labelIds.length > 0 ? (
<div>
<strong>Labels:</strong> {c.labelIds.length} filter(s)
</div>
) : null}
{c.query ? (
<div>
<strong>Search:</strong> "{c.query}"
</div>
) : null}
</div>
);
}
// -----------------------------------------------------------------------------
// Agent Detail Tab Component - Configure inbox for a specific agent
// -----------------------------------------------------------------------------
export function AgentInboxSettingsTab({ context }: PluginDetailTabProps) {
const { entityId, entityType } = context;
if (entityType !== "agent") {
return (
<div style={{ padding: "24px", fontSize: "13px", opacity: 0.7 }}>
This tab is only available on agent detail pages.
</div>
);
}
const agentId = entityId;
const toast = usePluginToast();
// Fetch current config
const configData = usePluginData<AgentInboxConfig | null>("getAgentInboxConfig", { agentId });
// Fetch projects for dropdown
const [projects, setProjects] = useState<Array<{ id: string; name: string }>>([]);
const [goals, setGoals] = useState<Array<{ id: string; title: string }>>([]);
const [loadingOptions, setLoadingOptions] = useState(true);
// Form state
const [formState, setFormState] = useState<AgentInboxConfig>({
...DEFAULT_INBOX_CONFIG,
statuses: configData.data?.statuses || DEFAULT_INBOX_CONFIG.statuses,
includeBacklog: configData.data?.includeBacklog || DEFAULT_INBOX_CONFIG.includeBacklog,
projectId: configData.data?.projectId || null,
goalId: configData.data?.goalId || null,
labelIds: configData.data?.labelIds || [],
query: configData.data?.query || null,
});
const [saving, setSaving] = useState(false);
// Load projects and goals
useEffect(() => {
async function loadOptions() {
if (!context.companyId) return;
setLoadingOptions(true);
try {
const [projectsRes, goalsRes] = await Promise.all([
fetch(`/api/companies/${context.companyId}/projects`, { credentials: "include" }),
fetch(`/api/companies/${context.companyId}/goals`, { credentials: "include" }),
]);
if (projectsRes.ok) {
const data = await projectsRes.json();
setProjects(Array.isArray(data) ? data : []);
}
if (goalsRes.ok) {
const data = await goalsRes.json();
setGoals(Array.isArray(data) ? data : []);
}
} catch (err) {
console.error("Failed to load options:", err);
} finally {
setLoadingOptions(false);
}
}
void loadOptions();
}, [context.companyId]);
// Update form when config data loads
useEffect(() => {
if (configData.data) {
setFormState({ ...DEFAULT_INBOX_CONFIG, ...configData.data });
}
}, [configData.data]);
const saveConfig = usePluginAction("setAgentInboxConfig");
async function handleSave(e: FormEvent) {
e.preventDefault();
if (!agentId) return;
setSaving(true);
try {
await saveConfig({
agentId,
config: {
statuses: formState.statuses,
includeBacklog: formState.includeBacklog,
projectId: formState.projectId,
goalId: formState.goalId,
labelIds: formState.labelIds,
query: formState.query,
},
});
toast({
title: "Inbox config saved",
body: `Configuration updated for this agent.`,
tone: "success",
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast({
title: "Failed to save config",
body: message,
tone: "error",
});
} finally {
setSaving(false);
}
}
const statusOptions = getStatusOptions();
const selectedStatuses = formState.statuses.split(",");
return (
<div style={{ padding: "20px", maxWidth: "700px" }}>
<h2 style={{ fontSize: "18px", fontWeight: 600, marginBottom: "8px" }}>Inbox Configuration</h2>
<p style={{ ...mutedTextStyle, marginBottom: "20px", fontSize: "13px" }}>
Configure which issues appear in this agent's inbox via the inbox-lite endpoint.
</p>
<form onSubmit={handleSave} style={{ display: "grid", gap: "20px" }}>
{/* Statuses */}
<div style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: "12px", fontSize: "14px" }}>Issue Statuses</div>
<p style={{ ...mutedTextStyle, marginBottom: "12px", fontSize: "12px" }}>
Select which issue statuses should appear in this agent's inbox.
</p>
<div style={{ display: "grid", gap: "8px" }}>
{statusOptions.map((option) => (
<label key={option.value} style={{ display: "flex", alignItems: "center", gap: "10px", fontSize: "13px" }}>
<input
type="checkbox"
checked={selectedStatuses.includes(option.value)}
onChange={(e) => {
const isChecked = e.target.checked;
const current = formState.statuses.split(",");
const next = isChecked
? [...current, option.value].filter(Boolean)
: current.filter((s) => s !== option.value);
setFormState({ ...formState, statuses: next.join(",") });
}}
style={checkboxStyle}
/>
<span>{option.label}</span>
</label>
))}
</div>
</div>
{/* Project Filter */}
<div style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: "12px", fontSize: "14px" }}>Project Filter</div>
<p style={{ ...mutedTextStyle, marginBottom: "12px", fontSize: "12px" }}>
Optionally limit inbox to issues from a specific project.
</p>
<select
style={selectStyle}
value={formState.projectId || ""}
onChange={(e) => setFormState({ ...formState, projectId: e.target.value || null })}
disabled={loadingOptions}
>
<option value="">No filter (all projects)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
{/* Goal Filter */}
<div style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: "12px", fontSize: "14px" }}>Goal Filter</div>
<p style={{ ...mutedTextStyle, marginBottom: "12px", fontSize: "12px" }}>
Optionally limit inbox to issues linked to a specific goal.
</p>
<select
style={selectStyle}
value={formState.goalId || ""}
onChange={(e) => setFormState({ ...formState, goalId: e.target.value || null })}
disabled={loadingOptions}
>
<option value="">No filter (all goals)</option>
{goals.map((goal) => (
<option key={goal.id} value={goal.id}>{goal.title}</option>
))}
</select>
</div>
{/* Search Query */}
<div style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: "12px", fontSize: "14px" }}>Search Query</div>
<p style={{ ...mutedTextStyle, marginBottom: "12px", fontSize: "12px" }}>
Optional search query to filter issues by title, description, or comments.
</p>
<input
type="text"
style={inputStyle}
value={formState.query || ""}
onChange={(e) => setFormState({ ...formState, query: e.target.value || null })}
placeholder="e.g., docker, deployment, urgent"
/>
</div>
{/* Include Backlog Toggle */}
<div style={cardStyle}>
<label style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<input
type="checkbox"
checked={formState.includeBacklog}
onChange={(e) => setFormState({ ...formState, includeBacklog: e.target.checked })}
style={checkboxStyle}
/>
<span style={{ fontSize: "13px" }}>Include backlog issues</span>
</label>
<p style={{ ...mutedTextStyle, marginTop: "6px", fontSize: "12px" }}>
When enabled, issues with status "backlog" will be included in the inbox.
</p>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "10px", justifyContent: "flex-end" }}>
<button
type="button"
style={buttonStyle}
onClick={() => {
setFormState({ ...DEFAULT_INBOX_CONFIG });
}}
>
Reset to Defaults
</button>
<button
type="submit"
style={primaryButtonStyle}
disabled={saving || configData.loading || loadingOptions}
>
{saving ? "Saving…" : "Save Configuration"}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { definePlugin, runWorker, type PaperclipPlugin } from "@paperclipai/plugin-sdk";
import {
DEFAULT_INBOX_CONFIG,
PLUGIN_ID,
PLUGIN_VERSION,
type AgentInboxConfig,
} from "./constants.js";
const WORKER_KEY = "agent-inbox-config-worker";
const plugin: PaperclipPlugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_ID} v${PLUGIN_VERSION} starting...`);
// Initialize plugin state with default inbox configs per agent
const existingState = await ctx.state.get({ scopeKind: "instance", stateKey: "agentInboxConfigs" });
if (!existingState) {
await ctx.state.set({ scopeKind: "instance", stateKey: "agentInboxConfigs" }, {});
}
const versionState = await ctx.state.get({ scopeKind: "instance", stateKey: "version" });
if (!versionState) {
await ctx.state.set({ scopeKind: "instance", stateKey: "version" }, PLUGIN_VERSION);
}
// Register data handlers for UI to fetch data
ctx.data.register("agents", async (params) => {
const companyId = typeof params?.companyId === "string" ? params.companyId : "";
if (!companyId) return [];
return await ctx.agents.list({ companyId, limit: 100, offset: 0 });
});
ctx.data.register("getAgentInboxConfig", async (params) => {
const agentId = typeof params?.agentId === "string" ? params.agentId : "";
if (!agentId) return null;
const configs = (await ctx.state.get({ scopeKind: "instance", stateKey: "agentInboxConfigs" })) || {};
return (configs as Record<string, AgentInboxConfig>)[agentId] || null;
});
ctx.data.register("listAgentInboxConfigs", async () => {
const configs = (await ctx.state.get({ scopeKind: "instance", stateKey: "agentInboxConfigs" })) || {};
return Object.entries(configs).map(([agentId, config]) => ({ agentId, config }));
});
// Register action handlers for UI to perform actions
ctx.actions.register("setAgentInboxConfig", async (params) => {
const agentId = typeof params?.agentId === "string" ? params.agentId : "";
const config = params?.config as Partial<AgentInboxConfig>;
if (!agentId) {
throw new Error("agentId is required");
}
const configs = (await ctx.state.get({ scopeKind: "instance", stateKey: "agentInboxConfigs" })) || {};
// Merge with defaults
const mergedConfig: AgentInboxConfig = {
...DEFAULT_INBOX_CONFIG,
statuses: config?.statuses || DEFAULT_INBOX_CONFIG.statuses,
includeBacklog: config?.includeBacklog ?? DEFAULT_INBOX_CONFIG.includeBacklog,
projectId: config?.projectId ?? null,
goalId: config?.goalId ?? null,
labelIds: config?.labelIds || [],
query: config?.query ?? null,
};
(configs as Record<string, AgentInboxConfig>)[agentId] = mergedConfig;
await ctx.state.set({ scopeKind: "instance", stateKey: "agentInboxConfigs" }, configs);
// Also persist to agent's runtimeConfig for durability and server-side access
try {
const agentResponse = await ctx.http.fetch(`/api/agents/${agentId}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (agentResponse.ok) {
const agentData = await agentResponse.json();
const currentRuntimeConfig = agentData.runtimeConfig || {};
const agentUpdateResponse = await ctx.http.fetch(`/api/agents/${agentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
runtimeConfig: {
...currentRuntimeConfig,
inboxConfig: mergedConfig,
},
}),
});
if (!agentUpdateResponse.ok) {
ctx.logger.warn("Failed to update agent runtimeConfig with inbox config", { agentId, status: agentUpdateResponse.status });
}
}
} catch (err) {
ctx.logger.warn("Failed to persist inbox config to agent runtimeConfig", { agentId, error: err });
}
ctx.logger.info("Inbox config updated for agent", { agentId });
return { success: true, agentId, config: mergedConfig };
});
ctx.actions.register("resetAgentInboxConfig", async (params) => {
const agentId = typeof params?.agentId === "string" ? params.agentId : "";
if (!agentId) {
throw new Error("agentId is required");
}
const configs = (await ctx.state.get({ scopeKind: "instance", stateKey: "agentInboxConfigs" })) || {};
delete (configs as Record<string, AgentInboxConfig>)[agentId];
await ctx.state.set({ scopeKind: "instance", stateKey: "agentInboxConfigs" }, configs);
// Also clear inboxConfig from agent's runtimeConfig
try {
const agentResponse = await ctx.http.fetch(`/api/agents/${agentId}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (agentResponse.ok) {
const agentData = await agentResponse.json();
const currentRuntimeConfig = agentData.runtimeConfig || {};
// Remove inboxConfig from runtimeConfig
const updatedRuntimeConfig = { ...currentRuntimeConfig };
delete updatedRuntimeConfig.inboxConfig;
const agentUpdateResponse = await ctx.http.fetch(`/api/agents/${agentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
runtimeConfig: updatedRuntimeConfig,
}),
});
if (!agentUpdateResponse.ok) {
ctx.logger.warn("Failed to clear agent runtimeConfig inbox config", { agentId, status: agentUpdateResponse.status });
}
}
} catch (err) {
ctx.logger.warn("Failed to clear inbox config from agent runtimeConfig", { agentId, error: err });
}
ctx.logger.info("Inbox config reset for agent", { agentId });
return { success: true, agentId };
});
// Event handler for agent creation - set default inbox config
ctx.events.on("agent.created", async (event) => {
const agentId = event.entityId || "";
if (!agentId) return;
const configs = (await ctx.state.get({ scopeKind: "instance", stateKey: "agentInboxConfigs" })) || {};
// Set default config for new agent in plugin state
(configs as Record<string, AgentInboxConfig>)[agentId] = {
...DEFAULT_INBOX_CONFIG,
};
await ctx.state.set({ scopeKind: "instance", stateKey: "agentInboxConfigs" }, configs);
// Also set default inboxConfig in agent's runtimeConfig
try {
const agentResponse = await ctx.http.fetch(`/api/agents/${agentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
runtimeConfig: {
inboxConfig: { ...DEFAULT_INBOX_CONFIG },
},
}),
});
if (!agentResponse.ok) {
ctx.logger.warn("Failed to set default inbox config on new agent", { agentId, status: agentResponse.status });
}
} catch (err) {
ctx.logger.warn("Failed to set default inbox config on new agent runtimeConfig", { agentId, error: err });
}
ctx.logger.info("Default inbox config set for new agent", { agentId });
});
ctx.logger.info(`${PLUGIN_ID} initialized successfully`);
},
async onHealth() {
return {
status: "ok",
message: "Agent Inbox Config plugin ready",
details: {},
};
},
});
export default plugin;
runWorker(plugin, import.meta.url);